@miraj181/ipingyou 2.0.10 → 2.0.12

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.
@@ -0,0 +1,66 @@
1
+ import { execa } from 'execa';
2
+ import chalk from 'chalk';
3
+ import { createSpinner, tunnelSpinner } from './animations.js';
4
+ import { killProcessTree, trackPID, untrackPID } from './cleanup.js';
5
+
6
+ export async function spawnTunnelSupervised(targetUrl, onUrlGenerated) {
7
+ let isShuttingDown = false;
8
+ let activeChild = null;
9
+
10
+ const loop = async () => {
11
+ while (!isShuttingDown) {
12
+ const spinner = createSpinner('Starting Cloudflare tunnel...', tunnelSpinner).start();
13
+
14
+ await new Promise((resolve) => {
15
+ activeChild = execa('cloudflared', ['tunnel', '--url', targetUrl], {
16
+ reject: false,
17
+ all: true,
18
+ });
19
+
20
+ trackPID(activeChild.pid);
21
+ let resolved = false;
22
+
23
+ activeChild.all.on('data', (chunk) => {
24
+ const text = chunk.toString();
25
+ const match = text.match(/https:\/\/[-0-9a-z]+\.trycloudflare\.com/);
26
+ if (match && !resolved) {
27
+ resolved = true;
28
+ spinner.succeed(`Tunnel active: ${chalk.cyan(match[0])}`);
29
+ onUrlGenerated(match[0]);
30
+ }
31
+ });
32
+
33
+ activeChild.on('exit', (code) => {
34
+ untrackPID(activeChild.pid);
35
+ if (!resolved) {
36
+ spinner.fail('Cloudflare tunnel exited before generating URL');
37
+ } else if (!isShuttingDown) {
38
+ console.log(chalk.yellow(`\n ⚠️ Tunnel disconnected (code ${code}). Restarting...`));
39
+ }
40
+ resolve();
41
+ });
42
+
43
+ activeChild.on('error', (err) => {
44
+ untrackPID(activeChild.pid);
45
+ spinner.fail(`Tunnel error: ${err.message}`);
46
+ resolve();
47
+ });
48
+ });
49
+
50
+ if (!isShuttingDown) {
51
+ await new Promise(r => setTimeout(r, 2000));
52
+ }
53
+ }
54
+ };
55
+
56
+ loop();
57
+
58
+ return {
59
+ kill: () => {
60
+ isShuttingDown = true;
61
+ if (activeChild) {
62
+ killProcessTree(activeChild.pid).finally(() => untrackPID(activeChild.pid));
63
+ }
64
+ }
65
+ };
66
+ }
@@ -18,132 +18,17 @@ import inquirer from 'inquirer';
18
18
  import fs from 'node:fs';
19
19
  import path from 'node:path';
20
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';
21
+ import { cleanupAll, trackPID, untrackPID, addCleanupHook } from '../lib/cleanup.js';
24
22
  import { createSpinner, sshSpinner, networkSpinner, fileTransferSpinner, showConnectionTrace, simulateTransferProgress } from '../lib/animations.js';
25
23
  import { getConfig, saveAlias } from '../lib/config.js';
24
+ import { pushTelemetry, resolveUID } from '../lib/broker.js';
25
+ import { calculateChecksum } from '../lib/checksum.js';
26
+ import { promptLocalPath, promptRemotePath } from '../lib/path-browser.js';
27
+ import { buildSshArgs, extractHostname, formatScpRemotePath, getSshControlOptions, quoteRemoteShell } from '../lib/ssh.js';
26
28
  import open from 'open';
27
29
 
28
30
  let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
29
31
 
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
- }
62
-
63
- /**
64
- * Resolve a UID to a tunnel URL via the broker.
65
- * The broker returns an encrypted blob; we decrypt locally.
66
- *
67
- * @param {string} uid
68
- * @param {string} password
69
- * @param {boolean} silent If true, suppresses the spinner animations
70
- * @returns {object|null} The decrypted payload
71
- */
72
- async function resolveUID(uid, password, silent = false) {
73
- const spinner = !silent ? createSpinner(`Resolving UID ${chalk.cyan(uid)}...`, networkSpinner).start() : null;
74
-
75
- try {
76
- const res = await fetch(`${BROKER_URL}/resolve/${uid}`);
77
-
78
- if (res.status === 404) {
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.'));
81
- return null;
82
- }
83
- if (res.status === 410) {
84
- if (spinner) spinner.fail('UID has expired — ask the host for a new session');
85
- else console.error(chalk.red(' ❌ UID expired.'));
86
- return null;
87
- }
88
- if (!res.ok) {
89
- const data = await res.json().catch(() => ({}));
90
- throw new Error(data.error || `HTTP ${res.status}`);
91
- }
92
-
93
- const data = await res.json();
94
-
95
- if (!data.iv || !data.ciphertext || !data.salt) {
96
- if (spinner) spinner.fail('Broker returned invalid response — missing encrypted data or salt');
97
- return null;
98
- }
99
-
100
- if (spinner) spinner.text = `Decrypting tunnel URL locally...`;
101
-
102
- // Simulate decryption delay for effect only if not silent
103
- if (!silent) await new Promise(r => setTimeout(r, 600));
104
-
105
- // Decrypt locally
106
- let decryptedPayload;
107
- try {
108
- decryptedPayload = decrypt(data.iv, data.ciphertext, password, data.salt);
109
- } catch (decryptErr) {
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.'));
112
- return null;
113
- }
114
-
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
- if (spinner) spinner.fail('Decrypted data is not a valid tunnel URL (incorrect password)');
128
- return null;
129
- }
130
-
131
- if (spinner) spinner.succeed(`Resolved: ${chalk.dim(payloadConfig.url)} ${chalk.green('[decrypted locally]')}`);
132
- return payloadConfig;
133
- } catch (err) {
134
- if (spinner) spinner.fail(`Broker lookup failed: ${err.message}`);
135
- return null;
136
- }
137
- }
138
-
139
- function extractHostname(url) {
140
- try {
141
- return new URL(url).hostname;
142
- } catch {
143
- return url.replace(/^https?:\/\//, '').replace(/\/$/, '');
144
- }
145
- }
146
-
147
32
  async function promptUsername() {
148
33
  const { username } = await inquirer.prompt([
149
34
  {
@@ -167,24 +52,16 @@ async function connectSSH(username, hostname, privateKeyPath) {
167
52
 
168
53
  await showConnectionTrace('Local', 'Remote SSH');
169
54
 
170
- const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
171
-
172
55
  try {
173
56
  const spinner = createSpinner('Handshaking...', sshSpinner).start();
174
57
  await new Promise(r => setTimeout(r, 800));
175
58
  spinner.succeed('Connection established! Handing over to terminal...');
176
59
  console.log('');
177
60
 
178
- const sshArgs = [
179
- '-o', `ProxyCommand=${proxyCommand}`,
180
- '-o', 'StrictHostKeyChecking=accept-new',
61
+ const sshArgs = buildSshArgs(hostname, privateKeyPath, [
181
62
  '-o', 'ServerAliveInterval=30',
182
63
  '-o', 'ServerAliveCountMax=3'
183
- ];
184
-
185
- if (privateKeyPath) {
186
- sshArgs.push('-i', privateKeyPath, '-o', 'IdentitiesOnly=yes');
187
- }
64
+ ]);
188
65
 
189
66
  sshArgs.push(`${username}@${hostname}`);
190
67
  sshArgs.push('-t', 'tmux new-session -A -s SecureLink_Session 2>/dev/null || exec $SHELL -l');
@@ -213,66 +90,6 @@ async function connectSSH(username, hostname, privateKeyPath) {
213
90
  }
214
91
  }
215
92
 
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
-
276
93
  /**
277
94
  * Perform an SCP file transfer through the Cloudflare tunnel.
278
95
  */
@@ -282,37 +99,16 @@ async function performSCP(username, hostname, direction, privateKeyPath) {
282
99
  console.log(chalk.dim(' ─────────────────────────────────'));
283
100
 
284
101
  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();
102
+ let remotePath;
103
+
104
+ if (direction === 'upload') {
105
+ remotePath = await promptRemotePath(username, hostname, privateKeyPath, 'destination');
106
+ localPath = await promptLocalPath('client file/folder to upload');
297
107
  } else {
298
- const ans = await inquirer.prompt([{
299
- type: 'input',
300
- name: 'localPath',
301
- message: `Local file/folder path:`,
302
- validate: v => v.trim().length > 0 || 'Required',
303
- }]);
304
- localPath = ans.localPath.trim();
108
+ localPath = await promptLocalPath('client destination');
109
+ remotePath = await promptRemotePath(username, hostname, privateKeyPath, 'source');
305
110
  }
306
111
 
307
- const { remotePath } = await inquirer.prompt([
308
- {
309
- type: 'input',
310
- name: 'remotePath',
311
- message: `Remote path (relative to ${username}'s home or absolute):`,
312
- validate: v => v.trim().length > 0 || 'Required',
313
- }
314
- ]);
315
-
316
112
  await showConnectionTrace('Local', 'Remote SCP');
317
113
 
318
114
  const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
@@ -321,17 +117,21 @@ async function performSCP(username, hostname, direction, privateKeyPath) {
321
117
  const scpArgs = [
322
118
  '-r', // recursive just in case
323
119
  '-o', `ProxyCommand=${proxyCommand}`,
324
- '-o', 'StrictHostKeyChecking=accept-new'
120
+ '-o', 'StrictHostKeyChecking=accept-new',
121
+ '-o', 'IdentitiesOnly=yes',
122
+ ...getSshControlOptions(hostname)
325
123
  ];
326
124
 
327
125
  if (privateKeyPath) {
328
- scpArgs.push('-i', privateKeyPath, '-o', 'IdentitiesOnly=yes');
126
+ scpArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
329
127
  }
330
128
 
129
+ const remoteSpec = `${username}@${hostname}:${formatScpRemotePath(remotePath)}`;
331
130
  if (direction === 'upload') {
332
- scpArgs.push(localPath, `${username}@${hostname}:${remotePath}`);
131
+ scpArgs.push(localPath, remoteSpec);
333
132
  } else {
334
- scpArgs.push(`${username}@${hostname}:${remotePath}`, localPath);
133
+ // Download: remote source FIRST, then local destination
134
+ scpArgs.push(remoteSpec, localPath);
335
135
  }
336
136
 
337
137
  let localHash = null;
@@ -364,10 +164,12 @@ async function performSCP(username, hostname, direction, privateKeyPath) {
364
164
  try {
365
165
  const sshArgs = [
366
166
  '-o', `ProxyCommand=${proxyCommand}`,
367
- '-o', 'StrictHostKeyChecking=accept-new'
167
+ '-o', 'StrictHostKeyChecking=accept-new',
168
+ '-o', 'IdentitiesOnly=yes',
169
+ ...getSshControlOptions(hostname)
368
170
  ];
369
- if (privateKeyPath) sshArgs.push('-i', privateKeyPath, '-o', 'IdentitiesOnly=yes');
370
- sshArgs.push(`${username}@${hostname}`, `shasum -a 256 "${remotePath}" || sha256sum "${remotePath}"`);
171
+ if (privateKeyPath) sshArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
172
+ sshArgs.push(`${username}@${hostname}`, `shasum -a 256 ${quoteRemoteShell(remotePath)} || sha256sum ${quoteRemoteShell(remotePath)}`);
371
173
 
372
174
  const { stdout } = await execa('ssh', sshArgs, { reject: false });
373
175
  const remoteHash = stdout.split(' ')[0].trim();
@@ -399,7 +201,7 @@ async function performSCP(username, hostname, direction, privateKeyPath) {
399
201
  /**
400
202
  * Main Client Mode entry point.
401
203
  */
402
- export async function startClientMode() {
204
+ export async function startClientMode(options = {}) {
403
205
  console.log('');
404
206
  console.log(chalk.bold.cyan(' 🌐 CLIENT MODE — Access a Remote Machine'));
405
207
  console.log(chalk.dim(' ──────────────────────────────────────────'));
@@ -462,6 +264,17 @@ export async function startClientMode() {
462
264
  }
463
265
  }
464
266
 
267
+ if (!targetUid && options.uid) {
268
+ targetUid = options.uid.trim();
269
+ const { password } = await inquirer.prompt([{
270
+ type: 'password',
271
+ name: 'password',
272
+ message: 'Enter the session password:',
273
+ validate: (v) => v.trim().length > 0 || 'Password is required to decrypt',
274
+ }]);
275
+ targetPassword = password.trim();
276
+ }
277
+
465
278
  if (!targetUid) {
466
279
  const answer = await inquirer.prompt([
467
280
  {
@@ -486,7 +299,7 @@ export async function startClientMode() {
486
299
  targetPassword = answer.password.trim();
487
300
  }
488
301
 
489
- const payload = await resolveUID(targetUid, targetPassword);
302
+ const payload = await resolveUID(BROKER_URL, targetUid, targetPassword);
490
303
  if (!payload) {
491
304
  process.exit(1);
492
305
  }
@@ -550,7 +363,7 @@ export async function startClientMode() {
550
363
  }
551
364
 
552
365
  // Push secure telemetry to host
553
- await pushTelemetry(targetUid, targetPassword, username);
366
+ await pushTelemetry(BROKER_URL, targetUid, targetPassword, username);
554
367
 
555
368
  const { action } = await inquirer.prompt([
556
369
  {
@@ -590,13 +403,15 @@ export async function startClientMode() {
590
403
  if (reconnect) {
591
404
  await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword);
592
405
  }
406
+
407
+ await cleanupAll();
593
408
  }
594
409
 
595
410
  async function handleClientChat(uid, password, cachedChatUrl) {
596
411
  let chatUrl = cachedChatUrl;
597
412
  const spinner = createSpinner('Checking for active chat room...', networkSpinner).start();
598
413
 
599
- const payload = await resolveUID(uid, password, true); // true = silent if possible, or just re-resolve
414
+ const payload = await resolveUID(BROKER_URL, uid, password, true); // true = silent if possible, or just re-resolve
600
415
  if (payload && payload.chatUrl) {
601
416
  chatUrl = payload.chatUrl;
602
417
  }
@@ -680,21 +495,13 @@ async function performReverseForward(username, hostname, privateKeyPath) {
680
495
  }
681
496
  ]);
682
497
 
683
- const proxyCmd = `cloudflared access ssh --hostname ${hostname}`;
684
498
  const portMap = `${remotePort}:localhost:${localPort}`;
685
499
 
686
- const sshArgs = [
500
+ const sshArgs = buildSshArgs(hostname, privateKeyPath, [
687
501
  '-N',
688
502
  '-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
- }
503
+ '-o', 'ExitOnForwardFailure=yes',
504
+ ]);
698
505
  sshArgs.push(`${username}@${hostname}`);
699
506
 
700
507
  console.log('');
@@ -712,4 +519,4 @@ async function performReverseForward(username, hostname, privateKeyPath) {
712
519
  if (err.killed) return;
713
520
  console.log(chalk.red(`\n ❌ Tunnel disconnected: ${err.message}`));
714
521
  }
715
- }
522
+ }