@miraj181/ipingyou 2.0.13 → 2.1.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.
@@ -21,11 +21,12 @@ import os from 'node:os';
21
21
  import { cleanupAll, trackPID, untrackPID, addCleanupHook } from '../lib/cleanup.js';
22
22
  import { createSpinner, sshSpinner, networkSpinner, fileTransferSpinner, showConnectionTrace, simulateTransferProgress } from '../lib/animations.js';
23
23
  import { getConfig, saveAlias } from '../lib/config.js';
24
- import { pushTelemetry, resolveUID } from '../lib/broker.js';
24
+ import { pushTelemetry, requestHostApproval, resolveUID, revokeUID, waitForApproval } from '../lib/broker.js';
25
25
  import { calculateChecksum } from '../lib/checksum.js';
26
26
  import { promptLocalPath, promptRemotePath } from '../lib/path-browser.js';
27
27
  import { buildSshArgs, extractHostname, formatScpRemotePath, getSshControlOptions, quoteRemoteShell } from '../lib/ssh.js';
28
28
  import open from 'open';
29
+ import { cleanupSessionLog, initSessionLog, logSessionEvent, recordEvent } from '../lib/session-log.js';
29
30
 
30
31
  let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
31
32
 
@@ -42,6 +43,28 @@ async function promptUsername() {
42
43
  return username.trim();
43
44
  }
44
45
 
46
+ function normalizePrivateKey(privateKey) {
47
+ const normalized = String(privateKey || '').replace(/\\n/g, '\n').replace(/\r\n/g, '\n');
48
+ return normalized.endsWith('\n') ? normalized : `${normalized}\n`;
49
+ }
50
+
51
+ async function writeEphemeralPrivateKey(privateKey) {
52
+ const keyPath = path.join(os.tmpdir(), `ipingyou_client_${Date.now()}`);
53
+ fs.writeFileSync(keyPath, normalizePrivateKey(privateKey), { mode: 0o600 });
54
+
55
+ const result = await execa('ssh-keygen', ['-y', '-f', keyPath], {
56
+ reject: false,
57
+ stdio: ['ignore', 'pipe', 'pipe'],
58
+ });
59
+
60
+ if (result.exitCode !== 0) {
61
+ try { fs.unlinkSync(keyPath); } catch { }
62
+ throw new Error(result.stderr.trim() || 'OpenSSH could not parse the host-provided private key');
63
+ }
64
+
65
+ return keyPath;
66
+ }
67
+
45
68
  /**
46
69
  * Start SSH connection through the Cloudflare tunnel.
47
70
  */
@@ -78,12 +101,15 @@ async function connectSSH(username, hostname, privateKeyPath) {
78
101
  if (result.exitCode === 0) {
79
102
  console.log('');
80
103
  console.log(chalk.green(' ✅ SSH session ended cleanly'));
104
+ recordEvent('ssh_session_ended', { hostname, exitCode: 0 });
81
105
  } else if (result.exitCode === 255) {
82
106
  console.log('');
83
107
  console.error(chalk.red(' ❌ SSH connection failed (exit code 255)'));
108
+ recordEvent('ssh_session_failed', { hostname, exitCode: 255 });
84
109
  } else {
85
110
  console.log('');
86
111
  console.error(chalk.red(` ❌ SSH exited with code ${result.exitCode}`));
112
+ recordEvent('ssh_session_ended', { hostname, exitCode: result.exitCode });
87
113
  }
88
114
  } catch (err) {
89
115
  console.error(chalk.red(` ❌ SSH error: ${err.message}`));
@@ -93,7 +119,48 @@ async function connectSSH(username, hostname, privateKeyPath) {
93
119
  /**
94
120
  * Perform an SCP file transfer through the Cloudflare tunnel.
95
121
  */
96
- async function performSCP(username, hostname, direction, privateKeyPath) {
122
+ async function chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath) {
123
+ if (!sharedDropPath) {
124
+ return promptRemotePath(username, hostname, privateKeyPath, direction === 'upload' ? 'destination' : 'source');
125
+ }
126
+
127
+ const { dropChoice } = await inquirer.prompt([{
128
+ type: 'list',
129
+ name: 'dropChoice',
130
+ message: direction === 'upload'
131
+ ? 'Where should the file/folder go on the host?'
132
+ : 'Where should browsing start on the host?',
133
+ choices: direction === 'upload'
134
+ ? [
135
+ { name: `📥 Use host shared drop folder (${sharedDropPath})`, value: 'drop' },
136
+ { name: '🔍 Browse host folders', value: 'browse' },
137
+ { name: '⌨️ Type host destination path manually', value: 'manual' }
138
+ ]
139
+ : [
140
+ { name: `📥 Start in host shared drop folder (${sharedDropPath})`, value: 'drop_browse' },
141
+ { name: '🔍 Browse from host home folder', value: 'browse' },
142
+ { name: '⌨️ Type host file/folder path manually', value: 'manual' }
143
+ ]
144
+ }]);
145
+
146
+ if (dropChoice === 'drop') return sharedDropPath;
147
+ if (dropChoice === 'drop_browse') {
148
+ return promptRemotePath(username, hostname, privateKeyPath, 'source', sharedDropPath);
149
+ }
150
+ if (dropChoice === 'manual') {
151
+ const { remotePath } = await inquirer.prompt([{
152
+ type: 'input',
153
+ name: 'remotePath',
154
+ message: direction === 'upload' ? 'Host destination path:' : 'Host file/folder path:',
155
+ validate: v => v.trim().length > 0 || 'Required',
156
+ }]);
157
+ return remotePath.trim();
158
+ }
159
+
160
+ return promptRemotePath(username, hostname, privateKeyPath, direction === 'upload' ? 'destination' : 'source');
161
+ }
162
+
163
+ async function performSCP(username, hostname, direction, privateKeyPath, sharedDropPath = null) {
97
164
  console.log('');
98
165
  console.log(chalk.bold(` 📦 SCP Transfer (${direction})`));
99
166
  console.log(chalk.dim(' ─────────────────────────────────'));
@@ -102,11 +169,11 @@ async function performSCP(username, hostname, direction, privateKeyPath) {
102
169
  let remotePath;
103
170
 
104
171
  if (direction === 'upload') {
105
- remotePath = await promptRemotePath(username, hostname, privateKeyPath, 'destination');
172
+ remotePath = await chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath);
106
173
  localPath = await promptLocalPath('client file/folder to upload');
107
174
  } else {
108
175
  localPath = await promptLocalPath('client destination');
109
- remotePath = await promptRemotePath(username, hostname, privateKeyPath, 'source');
176
+ remotePath = await chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath);
110
177
  }
111
178
 
112
179
  await showConnectionTrace('Local', 'Remote SCP');
@@ -157,11 +224,12 @@ async function performSCP(username, hostname, direction, privateKeyPath) {
157
224
 
158
225
  if (result.exitCode === 0) {
159
226
  await simulateTransferProgress(direction === 'upload' ? localPath : remotePath, direction, 1500);
160
-
227
+
161
228
  // Verify Checksum
162
229
  if (direction === 'upload' && localHash) {
163
230
  console.log(chalk.dim(' 🔍 Verifying remote SHA-256 checksum...'));
164
231
  try {
232
+ const remoteChecksumPath = joinRemotePath(remotePath, path.basename(localPath));
165
233
  const sshArgs = [
166
234
  '-o', `ProxyCommand=${proxyCommand}`,
167
235
  '-o', 'StrictHostKeyChecking=accept-new',
@@ -169,11 +237,11 @@ async function performSCP(username, hostname, direction, privateKeyPath) {
169
237
  ...getSshControlOptions(hostname)
170
238
  ];
171
239
  if (privateKeyPath) sshArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
172
- sshArgs.push(`${username}@${hostname}`, `shasum -a 256 ${quoteRemoteShell(remotePath)} || sha256sum ${quoteRemoteShell(remotePath)}`);
173
-
240
+ sshArgs.push(`${username}@${hostname}`, `shasum -a 256 ${quoteRemoteShell(remoteChecksumPath)} 2>/dev/null || sha256sum ${quoteRemoteShell(remoteChecksumPath)} 2>/dev/null || shasum -a 256 ${quoteRemoteShell(remotePath)} 2>/dev/null || sha256sum ${quoteRemoteShell(remotePath)}`);
241
+
174
242
  const { stdout } = await execa('ssh', sshArgs, { reject: false });
175
243
  const remoteHash = stdout.split(' ')[0].trim();
176
-
244
+
177
245
  if (remoteHash === localHash) {
178
246
  console.log(chalk.green(` ✅ Zero-Trust File Integrity: Hash match (${remoteHash.substring(0, 16)}...)`));
179
247
  } else {
@@ -189,15 +257,40 @@ async function performSCP(username, hostname, direction, privateKeyPath) {
189
257
  }
190
258
 
191
259
  console.log(chalk.green(` ✅ Transfer completed successfully!`));
260
+ recordEvent('scp_transfer_success', { direction, localPath, remotePath, hostname });
192
261
  } else {
193
262
  console.error(chalk.red(' ❌ SCP transfer failed'));
194
263
  if (result.stderr) console.error(chalk.dim(` ${result.stderr.trim()}`));
264
+ recordEvent('scp_transfer_failed', { direction, localPath, remotePath, hostname, error: result.stderr });
195
265
  }
196
266
  } catch (err) {
197
267
  console.error(chalk.red(` ❌ SCP error: ${err.message}`));
198
268
  }
199
269
  }
200
270
 
271
+ async function downloadSpecificRemotePath(username, hostname, privateKeyPath, remotePath, localPath) {
272
+ await showConnectionTrace('Local', 'Remote SCP');
273
+ const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
274
+ const scpArgs = [
275
+ '-r',
276
+ '-o', `ProxyCommand=${proxyCommand}`,
277
+ '-o', 'StrictHostKeyChecking=accept-new',
278
+ '-o', 'IdentitiesOnly=yes',
279
+ ...getSshControlOptions(hostname),
280
+ ];
281
+ if (privateKeyPath) scpArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
282
+ scpArgs.push(`${username}@${hostname}:${formatScpRemotePath(remotePath)}`, localPath);
283
+ const result = await execa('scp', scpArgs, { stdio: 'inherit', reject: false });
284
+ return result.exitCode === 0;
285
+ }
286
+
287
+ function joinRemotePath(parent, child) {
288
+ const cleanParent = String(parent || '').replace(/\/+$/, '');
289
+ if (!cleanParent) return child;
290
+ if (cleanParent === '/') return `/${child}`;
291
+ return `${cleanParent}/${child}`;
292
+ }
293
+
201
294
  /**
202
295
  * Main Client Mode entry point.
203
296
  */
@@ -207,6 +300,13 @@ export async function startClientMode(options = {}) {
207
300
  console.log(chalk.dim(' ──────────────────────────────────────────'));
208
301
  console.log('');
209
302
 
303
+ const sessionLogPath = initSessionLog('client');
304
+ if (sessionLogPath) {
305
+ console.log(chalk.dim(` 📜 Session log: ${sessionLogPath}`));
306
+ addCleanupHook(() => cleanupSessionLog());
307
+ }
308
+ logSessionEvent('client_mode_started');
309
+
210
310
  // Allow setting a custom broker URL if process.env isn't overridden by CLI
211
311
  if (process.env.BROKER_URL) {
212
312
  BROKER_URL = process.env.BROKER_URL;
@@ -234,11 +334,12 @@ export async function startClientMode(options = {}) {
234
334
  BROKER_URL = customBroker.trim();
235
335
  process.env.BROKER_URL = BROKER_URL; // Update for consistency
236
336
  }
337
+ logSessionEvent('client_broker_selected', { choice: brokerChoice, broker: BROKER_URL });
237
338
  }
238
339
 
239
340
  const config = getConfig();
240
341
  const aliasKeys = Object.keys(config.aliases || {});
241
-
342
+
242
343
  let targetUid = null;
243
344
  let targetPassword = null;
244
345
  let targetUsername = null;
@@ -261,6 +362,7 @@ export async function startClientMode(options = {}) {
261
362
  targetUid = aliasData.uid;
262
363
  targetPassword = aliasData.password;
263
364
  targetUsername = aliasData.username;
365
+ logSessionEvent('client_alias_selected', { alias: useAlias, uid: targetUid });
264
366
  }
265
367
  }
266
368
 
@@ -273,6 +375,7 @@ export async function startClientMode(options = {}) {
273
375
  validate: (v) => v.trim().length > 0 || 'Password is required to decrypt',
274
376
  }]);
275
377
  targetPassword = password.trim();
378
+ logSessionEvent('client_uid_provided', { uid: targetUid });
276
379
  }
277
380
 
278
381
  if (!targetUid) {
@@ -297,10 +400,61 @@ export async function startClientMode(options = {}) {
297
400
  ]);
298
401
  targetUid = answer.uid.trim();
299
402
  targetPassword = answer.password.trim();
403
+ logSessionEvent('client_uid_provided', { uid: targetUid });
404
+ }
405
+
406
+ let payload = await resolveUID(BROKER_URL, targetUid, targetPassword);
407
+
408
+ // ─── Approval Flow ──────────────────────────────────────────
409
+ if (payload && payload.needsApproval) {
410
+ console.log('');
411
+ console.log(chalk.bold.yellow(' 🔐 Host Approval Required'));
412
+ console.log(chalk.dim(' ──────────────────────────────────────'));
413
+ console.log(chalk.dim(' The host has enabled approval gating. Submitting your access request...'));
414
+
415
+ try {
416
+ const approvalDetails = {
417
+ username: os.userInfo().username,
418
+ hostname: os.hostname(),
419
+ os: `${os.type()} ${os.release()} (${os.arch()})`,
420
+ intent: 'connect',
421
+ time: new Date().toISOString(),
422
+ };
423
+
424
+ const { requestId, status: reqStatus, approvalRequired } = await requestHostApproval(
425
+ BROKER_URL, targetUid, targetPassword, approvalDetails
426
+ );
427
+
428
+ logSessionEvent('client_approval_submitted', { uid: targetUid, requestId });
429
+
430
+ if (!approvalRequired || reqStatus === 'approved') {
431
+ console.log(chalk.green(' ✅ Access auto-approved (host does not require manual approval for this session).'));
432
+ payload = await resolveUID(BROKER_URL, targetUid, targetPassword, false, requestId);
433
+ } else {
434
+ console.log(chalk.yellow(` ⏳ Waiting for host to approve your request (ID: ${requestId})...`));
435
+ console.log(chalk.dim(' This may take a few minutes. Press Ctrl+C to cancel.'));
436
+ console.log('');
437
+
438
+ const approved = await waitForApproval(BROKER_URL, targetUid, requestId, 300000);
439
+
440
+ if (approved) {
441
+ console.log(chalk.green(' ✅ Host approved your access request!'));
442
+ logSessionEvent('client_approval_granted', { uid: targetUid, requestId });
443
+ payload = await resolveUID(BROKER_URL, targetUid, targetPassword, false, requestId);
444
+ } else {
445
+ console.log(chalk.red(' ❌ Host denied your access request.'));
446
+ logSessionEvent('client_approval_denied', { uid: targetUid, requestId });
447
+ process.exit(1);
448
+ }
449
+ }
450
+ } catch (err) {
451
+ console.log(chalk.red(` ❌ Approval flow failed: ${err.message}`));
452
+ logSessionEvent('client_approval_error', { uid: targetUid, error: err.message }, 'error');
453
+ process.exit(1);
454
+ }
300
455
  }
301
456
 
302
- const payload = await resolveUID(BROKER_URL, targetUid, targetPassword);
303
- if (!payload) {
457
+ if (!payload || payload.needsApproval) {
304
458
  process.exit(1);
305
459
  }
306
460
 
@@ -308,9 +462,17 @@ export async function startClientMode(options = {}) {
308
462
  console.log('');
309
463
  console.log(chalk.bold(' 🌐 HTTP Service Exposed'));
310
464
  console.log(chalk.dim(' ─────────────────────────────────'));
311
- console.log(chalk.green(` Open this URL in your browser:\n 👉 ${chalk.bold.cyan(payload.url)}`));
465
+ console.log(chalk.green(` Opening in browser: ${chalk.bold.cyan(payload.url)}`));
312
466
  console.log('');
313
- process.exit(0);
467
+ logSessionEvent('client_http_mode', { uid: targetUid, port: payload.port || null });
468
+ try {
469
+ await open(payload.url);
470
+ } catch {
471
+ console.log(chalk.dim(` Could not auto-open. Please visit: ${payload.url}`));
472
+ }
473
+ console.log(chalk.dim(' Press Ctrl+C to exit.'));
474
+ // Keep process alive so cleanup handlers work
475
+ await new Promise(() => {});
314
476
  }
315
477
 
316
478
  if (payload.type === 'tcp') {
@@ -318,12 +480,40 @@ export async function startClientMode(options = {}) {
318
480
  console.log(chalk.bold(' 🔌 Custom TCP Port Exposed'));
319
481
  console.log(chalk.dim(' ─────────────────────────────────'));
320
482
  console.log(` The host is exposing a generic TCP service on port ${payload.port}.`);
321
- console.log(` To connect, run this command in a separate terminal:`);
322
- console.log(chalk.cyan(` cloudflared access tcp --hostname ${extractHostname(payload.url)} --url 127.0.0.1:${payload.port}`));
323
- console.log('');
324
- console.log(` Then connect your local client (e.g. Postgres, VNC, RDP) to ${chalk.green('127.0.0.1:' + payload.port)}`);
483
+
484
+ const hostname = extractHostname(payload.url);
485
+ const localPort = payload.port;
486
+
487
+ console.log(chalk.dim(` Starting local cloudflared proxy to ${hostname}...`));
325
488
  console.log('');
326
- process.exit(0);
489
+
490
+ try {
491
+ const { execa: execaFn } = await import('execa');
492
+ const child = execaFn('cloudflared', ['access', 'tcp', '--hostname', hostname, '--url', `127.0.0.1:${localPort}`], {
493
+ stdio: 'inherit',
494
+ reject: false,
495
+ });
496
+
497
+ trackPID(child.pid);
498
+ console.log(chalk.green(` ✅ Local proxy active! Connect your client to ${chalk.bold('127.0.0.1:' + localPort)}`));
499
+ console.log(chalk.dim(' Press Ctrl+C to terminate the tunnel.'));
500
+ logSessionEvent('client_tcp_mode', { uid: targetUid, port: localPort, hostname });
501
+
502
+ const result = await child;
503
+ untrackPID(child.pid);
504
+
505
+ if (result.exitCode !== 0) {
506
+ console.log(chalk.red(` ❌ Cloudflared exited with code ${result.exitCode}`));
507
+ }
508
+ } catch (err) {
509
+ console.log(chalk.red(` ❌ Could not start cloudflared proxy: ${err.message}`));
510
+ console.log(chalk.dim(' Fallback: run manually in another terminal:'));
511
+ console.log(chalk.cyan(` cloudflared access tcp --hostname ${hostname} --url 127.0.0.1:${localPort}`));
512
+ console.log('');
513
+ console.log(` Then connect your local client to ${chalk.green('127.0.0.1:' + localPort)}`);
514
+ }
515
+ await cleanupAll();
516
+ return;
327
517
  }
328
518
 
329
519
  const tunnelUrl = payload.url;
@@ -334,11 +524,57 @@ export async function startClientMode(options = {}) {
334
524
  let privateKeyPath = null;
335
525
  if (payload.privateKey) {
336
526
  console.log(chalk.green(' 🔑 Host provided an ephemeral SSH key for passwordless entry!'));
337
- privateKeyPath = path.join(os.tmpdir(), `ipingyou_client_${Date.now()}`);
338
- fs.writeFileSync(privateKeyPath, payload.privateKey, { mode: 0o600 });
339
- addCleanupHook(() => {
340
- try { fs.unlinkSync(privateKeyPath); } catch {}
341
- });
527
+ try {
528
+ privateKeyPath = await writeEphemeralPrivateKey(payload.privateKey);
529
+ addCleanupHook(() => {
530
+ try { fs.unlinkSync(privateKeyPath); } catch { }
531
+ });
532
+ logSessionEvent('client_ephemeral_key_ready');
533
+ } catch (err) {
534
+ console.log(chalk.yellow(` ⚠️ Could not use ephemeral SSH key: ${err.message}`));
535
+ console.log(chalk.dim(' Falling back to standard OS password.'));
536
+ logSessionEvent('client_ephemeral_key_failed', { error: err.message }, 'warn');
537
+ privateKeyPath = null;
538
+ }
539
+ }
540
+
541
+ // ─── One-Time File Share Auto-Download ────────────────────
542
+ if (payload.oneTime && payload.oneTimeSharePath) {
543
+ console.log('');
544
+ console.log(chalk.bold.magenta(' 📦 One-Time File Share'));
545
+ console.log(chalk.dim(' ──────────────────────────────────────'));
546
+ console.log(chalk.dim(` Host is sharing: ${chalk.cyan(payload.oneTimeSharePath)}`));
547
+ console.log(chalk.dim(' This is a one-time transfer — the session will be revoked after download.'));
548
+ console.log('');
549
+
550
+ const localDest = await promptLocalPath('download destination');
551
+
552
+ await pushTelemetry(BROKER_URL, targetUid, targetPassword, username, 'one-time-download');
553
+ logSessionEvent('client_one_time_download_start', { uid: targetUid, remotePath: payload.oneTimeSharePath });
554
+
555
+ const success = await downloadSpecificRemotePath(username, hostname, privateKeyPath, payload.oneTimeSharePath, localDest);
556
+
557
+ if (success) {
558
+ console.log(chalk.green(' ✅ One-time file transfer completed successfully!'));
559
+
560
+ // Verify download checksum
561
+ const dlHash = await calculateChecksum(localDest);
562
+ if (dlHash) console.log(chalk.green(` 🔍 File Intact. SHA-256: ${dlHash.substring(0, 16)}...`));
563
+
564
+ // Auto-revoke the UID from broker
565
+ console.log(chalk.dim(' Revoking session from broker...'));
566
+ await revokeUID(BROKER_URL, targetUid);
567
+ console.log(chalk.green(' 🔒 Session revoked. No further access is possible.'));
568
+
569
+ logSessionEvent('client_one_time_download_complete', { uid: targetUid });
570
+ recordEvent('one_time_transfer_complete', { uid: targetUid, localDest });
571
+ } else {
572
+ console.log(chalk.red(' ❌ One-time file transfer failed.'));
573
+ logSessionEvent('client_one_time_download_failed', { uid: targetUid }, 'error');
574
+ }
575
+
576
+ await cleanupAll();
577
+ return;
342
578
  }
343
579
 
344
580
  // Ask to save alias if we entered manually
@@ -359,12 +595,10 @@ export async function startClientMode(options = {}) {
359
595
  }]);
360
596
  saveAlias(aliasName.trim(), { uid: targetUid, password: targetPassword, username });
361
597
  console.log(chalk.green(` ✓ Saved as alias: ${chalk.bold(aliasName.trim())}\n`));
598
+ logSessionEvent('client_alias_saved', { alias: aliasName.trim(), uid: targetUid });
362
599
  }
363
600
  }
364
601
 
365
- // Push secure telemetry to host
366
- await pushTelemetry(BROKER_URL, targetUid, targetPassword, username);
367
-
368
602
  const { action } = await inquirer.prompt([
369
603
  {
370
604
  type: 'list',
@@ -380,6 +614,9 @@ export async function startClientMode(options = {}) {
380
614
  }
381
615
  ]);
382
616
 
617
+ await pushTelemetry(BROKER_URL, targetUid, targetPassword, username, action);
618
+ logSessionEvent('client_action_selected', { action });
619
+
383
620
  if (action === 'chat') {
384
621
  await handleClientChat(targetUid, targetPassword, payload.chatUrl);
385
622
  } else if (action === 'ssh') {
@@ -387,7 +624,7 @@ export async function startClientMode(options = {}) {
387
624
  } else if (action === 'reverse') {
388
625
  await performReverseForward(username, hostname, privateKeyPath);
389
626
  } else {
390
- await performSCP(username, hostname, action, privateKeyPath);
627
+ await performSCP(username, hostname, action, privateKeyPath, payload.sharedDropPath);
391
628
  }
392
629
 
393
630
  console.log('');
@@ -401,16 +638,57 @@ export async function startClientMode(options = {}) {
401
638
  ]);
402
639
 
403
640
  if (reconnect) {
404
- await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword);
641
+ await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, payload.sharedDropPath);
405
642
  }
406
643
 
644
+ logSessionEvent('client_session_exit');
407
645
  await cleanupAll();
408
646
  }
409
647
 
648
+ /**
649
+ * Perform a non-interactive SCP transfer (used by AI Transfer Assistant).
650
+ * params: { brokerUrl, uid, password, username, direction, localPath, remotePath }
651
+ */
652
+ export async function performSCPNonInteractive(params = {}) {
653
+ const { brokerUrl, uid, password, username, direction, localPath, remotePath } = params;
654
+ if (!brokerUrl || !uid || !password) throw new Error('Missing broker connection info');
655
+
656
+ const payload = await resolveUID(brokerUrl, uid, password);
657
+ if (!payload) throw new Error('Could not resolve UID/payload');
658
+
659
+ const tunnelUrl = payload.url;
660
+ const hostname = extractHostname(tunnelUrl);
661
+ const privateKeyPath = payload.privateKey ? await writeEphemeralPrivateKey(payload.privateKey) : null;
662
+
663
+ // Build scp args similar to performSCP
664
+ const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
665
+ const scpArgs = ['-r', '-o', `ProxyCommand=${proxyCommand}`, '-o', 'StrictHostKeyChecking=accept-new', '-o', 'IdentitiesOnly=yes', ...getSshControlOptions(hostname)];
666
+ if (privateKeyPath) scpArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
667
+
668
+ const remoteSpec = `${username}@${hostname}:${formatScpRemotePath(remotePath)}`;
669
+ if (direction === 'upload') {
670
+ scpArgs.push(localPath, remoteSpec);
671
+ } else {
672
+ scpArgs.push(remoteSpec, localPath);
673
+ }
674
+
675
+ try {
676
+ const result = await execa('scp', scpArgs, { stdio: 'inherit', reject: false });
677
+ if (result.exitCode === 0) {
678
+ recordEvent('scp_transfer_success', { direction, localPath, remotePath, hostname, automated: true });
679
+ return true;
680
+ }
681
+ recordEvent('scp_transfer_failed', { direction, localPath, remotePath, hostname, error: result.stderr, automated: true });
682
+ return false;
683
+ } finally {
684
+ try { if (privateKeyPath) fs.unlinkSync(privateKeyPath); } catch { }
685
+ }
686
+ }
687
+
410
688
  async function handleClientChat(uid, password, cachedChatUrl) {
411
689
  let chatUrl = cachedChatUrl;
412
690
  const spinner = createSpinner('Checking for active chat room...', networkSpinner).start();
413
-
691
+
414
692
  const payload = await resolveUID(BROKER_URL, uid, password, true); // true = silent if possible, or just re-resolve
415
693
  if (payload && payload.chatUrl) {
416
694
  chatUrl = payload.chatUrl;
@@ -429,7 +707,7 @@ async function handleClientChat(uid, password, cachedChatUrl) {
429
707
  }
430
708
  }
431
709
 
432
- async function handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword) {
710
+ async function handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, sharedDropPath = null) {
433
711
  const { action } = await inquirer.prompt([
434
712
  {
435
713
  type: 'list',
@@ -448,6 +726,9 @@ async function handleSubsequentActions(username, hostname, privateKeyPath, targe
448
726
 
449
727
  if (action === 'exit') return;
450
728
 
729
+ await pushTelemetry(BROKER_URL, targetUid, targetPassword, username, action);
730
+ logSessionEvent('client_action_selected', { action });
731
+
451
732
  if (action === 'chat') {
452
733
  await handleClientChat(targetUid, targetPassword, null);
453
734
  } else if (action === 'ssh') {
@@ -455,7 +736,7 @@ async function handleSubsequentActions(username, hostname, privateKeyPath, targe
455
736
  } else if (action === 'reverse') {
456
737
  await performReverseForward(username, hostname, privateKeyPath);
457
738
  } else {
458
- await performSCP(username, hostname, action, privateKeyPath);
739
+ await performSCP(username, hostname, action, privateKeyPath, sharedDropPath);
459
740
  }
460
741
 
461
742
  const { reconnect } = await inquirer.prompt([
@@ -468,7 +749,7 @@ async function handleSubsequentActions(username, hostname, privateKeyPath, targe
468
749
  ]);
469
750
 
470
751
  if (reconnect) {
471
- await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword);
752
+ await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, sharedDropPath);
472
753
  }
473
754
  }
474
755
 
@@ -507,16 +788,27 @@ async function performReverseForward(username, hostname, privateKeyPath) {
507
788
  console.log('');
508
789
  const spinner = createSpinner(`Forwarding Host:${remotePort} ➔ Localhost:${localPort}...`, networkSpinner).start();
509
790
 
791
+ let child = null;
510
792
  try {
511
- const child = execa('ssh', sshArgs, { stdio: 'inherit' });
793
+ child = execa('ssh', sshArgs, { stdio: 'inherit', reject: false });
512
794
  trackPID(child.pid);
513
795
  spinner.succeed(`Reverse tunnel active! Host can access your app at ${chalk.bold.green('localhost:' + remotePort)}`);
514
796
  console.log(chalk.dim(' Press Ctrl+C to terminate the reverse tunnel.'));
515
-
516
- await child;
797
+
798
+ recordEvent('reverse_forward_started', { localPort, remotePort, hostname });
799
+ const result = await child;
800
+ if (result.exitCode === 0) {
801
+ recordEvent('reverse_forward_ended', { localPort, remotePort, hostname, exitCode: 0 });
802
+ } else {
803
+ console.log(chalk.red(`\n ❌ Reverse tunnel exited with code ${result.exitCode}`));
804
+ recordEvent('reverse_forward_failed', { localPort, remotePort, hostname, exitCode: result.exitCode });
805
+ }
517
806
  } catch (err) {
518
807
  if (err.isCanceled) return;
519
808
  if (err.killed) return;
809
+ spinner.fail('Reverse tunnel failed to start');
520
810
  console.log(chalk.red(`\n ❌ Tunnel disconnected: ${err.message}`));
811
+ } finally {
812
+ if (child?.pid) untrackPID(child.pid);
521
813
  }
522
814
  }