@miraj181/ipingyou 2.0.9 → 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.
package/src/modes/host.js CHANGED
@@ -18,16 +18,31 @@ import chalk from 'chalk';
18
18
  import inquirer from 'inquirer';
19
19
  import path from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
+ import fs from 'node:fs';
22
+ import os from 'node:os';
21
23
  import { generateUID } from '../lib/uid.js';
22
- import { encrypt, decrypt } from '../lib/crypto.js';
23
- import { trackPID, untrackPID, setRevokeOnExit, addCleanupHook } from '../lib/cleanup.js';
24
+ import { decrypt } from '../lib/crypto.js';
25
+ import { cleanupAll, killProcessTree, trackPID, untrackPID, setRevokeOnExit, addCleanupHook } from '../lib/cleanup.js';
24
26
  import { detectOS } from '../lib/platform.js';
25
- import { createSpinner, cryptoSpinner, tunnelSpinner, networkSpinner, typeText } from '../lib/animations.js';
27
+ import { createSpinner, networkSpinner, typeText } from '../lib/animations.js';
26
28
  import { startChatServer, openLocalChatUI } from '../lib/chat.js';
29
+ import { spawnTunnelSupervised } from '../lib/tunnel.js';
30
+ import { pingBroker, registerWithBroker } from '../lib/broker.js';
27
31
 
28
32
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
33
  let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
30
34
 
35
+ async function waitForValue(getValue, timeoutMs, label) {
36
+ const startedAt = Date.now();
37
+ while (!getValue()) {
38
+ if (Date.now() - startedAt > timeoutMs) {
39
+ throw new Error(`${label} timed out after ${Math.round(timeoutMs / 1000)}s`);
40
+ }
41
+ await new Promise(r => setTimeout(r, 100));
42
+ }
43
+ return getValue();
44
+ }
45
+
31
46
  /**
32
47
  * Ensure the local SSH server is running.
33
48
  */
@@ -86,72 +101,47 @@ async function ensureSSHRunning() {
86
101
  }
87
102
 
88
103
  /**
89
- * Supervise cloudflared tunnel, restarting it if it crashes.
104
+ * Ensure tmux is installed for terminal mirroring.
90
105
  */
91
- async function spawnTunnelSupervised(targetUrl, onUrlGenerated) {
92
- let isShuttingDown = false;
93
- let activeChild = null;
94
-
95
- const loop = async () => {
96
- while (!isShuttingDown) {
97
- const spinner = createSpinner('Starting Cloudflare tunnel...', tunnelSpinner).start();
98
-
99
- await new Promise((resolve) => {
100
- activeChild = execa('cloudflared', ['tunnel', '--url', targetUrl], {
101
- reject: false,
102
- all: true,
103
- });
104
-
105
- trackPID(activeChild.pid);
106
- let tunnelUrl = null;
107
- let resolved = false;
108
-
109
- activeChild.all.on('data', (chunk) => {
110
- const text = chunk.toString();
111
- const match = text.match(/https:\/\/[-0-9a-z]+\.trycloudflare\.com/);
112
- if (match && !resolved) {
113
- tunnelUrl = match[0];
114
- resolved = true;
115
- spinner.succeed(`Tunnel active: ${chalk.cyan(tunnelUrl)}`);
116
- onUrlGenerated(tunnelUrl);
117
- }
118
- });
119
-
120
- activeChild.on('exit', (code) => {
121
- untrackPID(activeChild.pid);
122
- if (!resolved) {
123
- spinner.fail('Cloudflare tunnel exited before generating URL');
124
- } else if (!isShuttingDown) {
125
- console.log(chalk.yellow(`\n ⚠️ Tunnel disconnected (code ${code}). Restarting...`));
126
- }
127
- resolve(); // Let the loop continue to restart
128
- });
129
-
130
- activeChild.on('error', (err) => {
131
- untrackPID(activeChild.pid);
132
- spinner.fail(`Tunnel error: ${err.message}`);
133
- resolve();
134
- });
135
- });
136
-
137
- if (!isShuttingDown) {
138
- // Wait a bit before restarting to avoid tight loop
139
- await new Promise(r => setTimeout(r, 2000));
140
- }
141
- }
142
- };
143
-
144
- loop(); // Fire and forget the supervisor loop
106
+ async function ensureTmuxInstalled() {
107
+ const osInfo = detectOS();
108
+ if (osInfo.isWindows) return;
145
109
 
146
- return {
147
- kill: () => {
148
- isShuttingDown = true;
149
- if (activeChild) {
150
- untrackPID(activeChild.pid);
151
- try { process.kill(activeChild.pid); } catch { /* ignore */ }
110
+ const spinner = createSpinner('Checking tmux installation...', networkSpinner).start();
111
+ try {
112
+ try {
113
+ await execaCommand('tmux -V', { reject: true });
114
+ spinner.succeed('tmux is installed (Terminal Mirroring available)');
115
+ } catch {
116
+ spinner.text = 'tmux not found. Attempting to install...';
117
+ if (osInfo.isLinux) {
118
+ if (fs.existsSync('/usr/bin/apt') || fs.existsSync('/usr/bin/apt-get')) {
119
+ await execaCommand('sudo apt-get update && sudo apt-get install -y tmux', { shell: true, stdio: 'inherit' });
120
+ } else if (fs.existsSync('/usr/bin/dnf')) {
121
+ await execaCommand('sudo dnf install -y tmux', { shell: true, stdio: 'inherit' });
122
+ } else if (fs.existsSync('/usr/bin/yum')) {
123
+ await execaCommand('sudo yum install -y tmux', { shell: true, stdio: 'inherit' });
124
+ } else if (fs.existsSync('/usr/bin/pacman')) {
125
+ await execaCommand('sudo pacman -S --noconfirm tmux', { shell: true, stdio: 'inherit' });
126
+ } else if (fs.existsSync('/sbin/apk')) {
127
+ await execaCommand('sudo apk add tmux', { shell: true, stdio: 'inherit' });
128
+ } else {
129
+ throw new Error('Unsupported Linux package manager');
130
+ }
131
+ spinner.succeed('tmux installed successfully (Terminal Mirroring available)');
132
+ } else if (osInfo.isMac) {
133
+ try {
134
+ await execaCommand('brew install tmux', { shell: true, stdio: 'inherit' });
135
+ spinner.succeed('tmux installed successfully (Terminal Mirroring available)');
136
+ } catch {
137
+ throw new Error('Homebrew is required to install tmux on macOS');
138
+ }
152
139
  }
153
140
  }
154
- };
141
+ } catch (err) {
142
+ spinner.fail(`tmux check/install failed: ${err.message}`);
143
+ console.log(chalk.dim(' Terminal Mirroring feature will not be available.'));
144
+ }
155
145
  }
156
146
 
157
147
  // ─── Ephemeral SSH Key Management ────────────────────────────
@@ -188,21 +178,6 @@ async function removePublicKey(authKeysPath, pubKey) {
188
178
  }
189
179
  }
190
180
 
191
- /**
192
- * Ping the broker to see if it's online.
193
- */
194
- async function pingBroker(url) {
195
- try {
196
- const controller = new AbortController();
197
- const id = setTimeout(() => controller.abort(), 3000);
198
- const res = await fetch(`${url}/health`, { signal: controller.signal });
199
- clearTimeout(id);
200
- return res.ok;
201
- } catch {
202
- return false;
203
- }
204
- }
205
-
206
181
  /**
207
182
  * Auto-spawn a Private Broker locally and wrap it in a Cloudflare tunnel.
208
183
  */
@@ -212,19 +187,32 @@ async function spawnPrivateBroker() {
212
187
  // 1. Spawn the broker server process
213
188
  const brokerProcess = execa('node', [path.join(__dirname, '../server.js')], {
214
189
  env: { ...process.env, PORT: '4040' },
215
- reject: false
190
+ reject: false,
191
+ all: true,
216
192
  });
217
193
  trackPID(brokerProcess.pid);
218
194
 
195
+ let brokerExited = false;
196
+ let brokerOutput = '';
197
+ brokerProcess.all?.on('data', chunk => {
198
+ brokerOutput += chunk.toString();
199
+ });
200
+ brokerProcess.on('exit', () => {
201
+ brokerExited = true;
202
+ });
203
+
219
204
  // 2. Wrap it in a cloudflare tunnel
220
205
  let brokerTunnelUrl = null;
221
206
  const privateBrokerTunnelProcess = await spawnTunnelSupervised('http://localhost:4040', (newUrl) => {
222
207
  brokerTunnelUrl = newUrl;
223
208
  });
224
209
 
225
- while (!brokerTunnelUrl) {
226
- await new Promise(r => setTimeout(r, 100));
227
- }
210
+ await waitForValue(() => {
211
+ if (brokerExited) {
212
+ throw new Error(`Private broker exited before tunnel was ready${brokerOutput ? `: ${brokerOutput.trim()}` : ''}`);
213
+ }
214
+ return brokerTunnelUrl;
215
+ }, 30000, 'Private broker tunnel startup');
228
216
 
229
217
  console.log(chalk.green(` ✅ Private Broker Active: ${chalk.bold.cyan(brokerTunnelUrl)}\n`));
230
218
 
@@ -232,53 +220,11 @@ async function spawnPrivateBroker() {
232
220
  url: brokerTunnelUrl,
233
221
  kill: () => {
234
222
  privateBrokerTunnelProcess.kill();
235
- untrackPID(brokerProcess.pid);
236
- try { process.kill(brokerProcess.pid); } catch { /* ignore */ }
223
+ killProcessTree(brokerProcess.pid).finally(() => untrackPID(brokerProcess.pid));
237
224
  }
238
225
  };
239
226
  }
240
227
 
241
- /**
242
- * Encrypt tunnel details and register with the Central Broker.
243
- */
244
- async function registerWithBroker(uid, tunnelUrl, password, serviceConfig) {
245
- const spinner = createSpinner('Encrypting session data...', cryptoSpinner).start();
246
-
247
- try {
248
- // Encrypt the JSON payload LOCALLY before sending
249
- await new Promise(r => setTimeout(r, 600)); // animation effect
250
- const payload = JSON.stringify({ url: tunnelUrl, ...serviceConfig });
251
- const encrypted = encrypt(payload, password);
252
-
253
- spinner.text = 'Registering with broker...';
254
-
255
- const res = await fetch(`${BROKER_URL}/register`, {
256
- method: 'POST',
257
- headers: { 'Content-Type': 'application/json' },
258
- body: JSON.stringify({
259
- uid,
260
- iv: encrypted.iv,
261
- ciphertext: encrypted.ciphertext,
262
- salt: encrypted.salt,
263
- }),
264
- });
265
-
266
- if (!res.ok) {
267
- const data = await res.json().catch(() => ({}));
268
- throw new Error(data.error || `HTTP ${res.status}`);
269
- }
270
-
271
- spinner.succeed(`Registered with broker ${chalk.dim(`(${BROKER_URL})`)} ${chalk.green('[E2E encrypted]')}`);
272
- return true;
273
- } catch (err) {
274
- spinner.fail(`Broker registration failed: ${err.message}`);
275
- console.error(chalk.red(` ❌ Error: ${err.message}`));
276
- console.log(chalk.yellow(' ⚠️ Remote clients won\'t be able to find you without the broker.'));
277
- console.log(chalk.dim(' Share the tunnel URL directly if needed.'));
278
- return false;
279
- }
280
- }
281
-
282
228
  // Monitor active connections removed (replaced by Telemetry)
283
229
 
284
230
  /**
@@ -351,7 +297,7 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
351
297
  chatTunnelProcess = null;
352
298
  chatServerInstance = null;
353
299
  delete serviceConfig.chatUrl;
354
- await registerWithBroker(uid, tunnelUrl, password, serviceConfig);
300
+ await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
355
301
  renderDashboard();
356
302
  }
357
303
  });
@@ -359,13 +305,11 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
359
305
  console.log(chalk.dim(' Provisioning Cloudflare tunnel for chat...'));
360
306
  chatTunnelProcess = await spawnTunnelSupervised(`http://localhost:${chatServerInstance.port}`, async (newUrl) => {
361
307
  serviceConfig.chatUrl = newUrl;
362
- await registerWithBroker(uid, tunnelUrl, password, serviceConfig);
308
+ await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
363
309
  renderDashboard();
364
310
  });
365
311
 
366
- while (!serviceConfig.chatUrl) {
367
- await new Promise(r => setTimeout(r, 100));
368
- }
312
+ await waitForValue(() => serviceConfig.chatUrl, 30000, 'Chat tunnel startup');
369
313
 
370
314
  console.log(chalk.green(' ✅ Chat Room Live! Clients can now join.'));
371
315
  await openLocalChatUI(chatServerInstance.port, password);
@@ -381,14 +325,24 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
381
325
  console.log('');
382
326
  console.log(chalk.bold.cyan(' 📺 Terminal Mirroring'));
383
327
  console.log(chalk.dim(' ──────────────────────────────────────'));
384
- console.log(chalk.dim(' Attaching to client session. Press Ctrl+b then d to detach gracefully!'));
385
- console.log(chalk.dim(' If no client has connected yet or tmux is missing, this will fail.'));
328
+ console.log(chalk.dim(' Attaching to the tmux session created by an interactive SSH client.'));
329
+ console.log(chalk.dim(' Press Ctrl+b then d to detach gracefully.'));
386
330
  console.log('');
387
331
 
388
332
  try {
333
+ await execaCommand('tmux -V', { reject: true });
334
+ const sessionCheck = await execa('tmux', ['has-session', '-t', 'SecureLink_Session'], { reject: false });
335
+ if (sessionCheck.exitCode !== 0) {
336
+ console.log(chalk.yellow(' ⚠️ No mirrored terminal session is active yet.'));
337
+ console.log(chalk.dim(' A client must choose "Connect via SSH" first. SCP-only clients do not create a tmux session.'));
338
+ console.log(chalk.dim(' tmux is needed on the host machine only; the client does not need tmux.'));
339
+ return waitForAction();
340
+ }
389
341
  await execaCommand('tmux attach -t SecureLink_Session -r', { stdio: 'inherit' });
390
- } catch {
391
- console.log(chalk.yellow(' ⚠️ Could not attach. Ensure a client is connected and tmux is installed.'));
342
+ } catch (err) {
343
+ console.log(chalk.yellow(' ⚠️ Could not attach to tmux.'));
344
+ console.log(chalk.dim(` ${err.message}`));
345
+ console.log(chalk.dim(' Terminal mirroring requires tmux on the host machine and an active interactive SSH client.'));
392
346
  }
393
347
  return waitForAction();
394
348
  }
@@ -430,16 +384,17 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
430
384
  }
431
385
 
432
386
  case 'reregister':
433
- await registerWithBroker(uid, tunnelUrl, password, serviceConfig);
387
+ await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
434
388
  return waitForAction();
435
389
 
436
390
  case 'terminate': {
437
391
  const spinner = createSpinner('Terminating active SSH sessions...', networkSpinner).start();
438
392
  try {
439
393
  if (process.platform === 'win32') {
440
- await execaCommand('taskkill /F /IM sshd.exe', { reject: false });
394
+ await execaCommand('powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name = \'sshd.exe\'\\" | Where-Object { $_.CommandLine -match \'sshd:.*@\' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"', { reject: false });
441
395
  } else {
442
396
  await execaCommand("pkill -f 'sshd:.*@'", { shell: true, reject: false });
397
+ await execaCommand('tmux kill-session -t SecureLink_Session', { reject: false });
443
398
  }
444
399
  spinner.succeed('All client SSH sessions terminated');
445
400
  } catch {
@@ -449,9 +404,10 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
449
404
  }
450
405
 
451
406
  case 'exit':
452
- if (tunnelProcess) tunnelProcess.kill();
453
407
  if (chatTunnelProcess) chatTunnelProcess.kill();
454
408
  if (global.privateBrokerInstance) global.privateBrokerInstance.kill();
409
+ if (tunnelProcess) tunnelProcess.kill();
410
+ await cleanupAll();
455
411
  return;
456
412
  }
457
413
  } catch (err) {
@@ -565,6 +521,7 @@ export async function startHostMode() {
565
521
 
566
522
  if (serviceType === 'ssh') {
567
523
  await ensureSSHRunning();
524
+ await ensureTmuxInstalled();
568
525
  console.log(chalk.dim(' 🔑 Generating ephemeral SSH key for passwordless entry...'));
569
526
  try {
570
527
  const ephemeralKey = await generateEphemeralKey();
@@ -591,7 +548,7 @@ export async function startHostMode() {
591
548
  const tunnelProcess = await spawnTunnelSupervised(targetUrl, async (newUrl) => {
592
549
  tunnelUrl = newUrl;
593
550
  // Register or re-register with broker when tunnel is spawned/respawned
594
- const registered = await registerWithBroker(uid, tunnelUrl, password, serviceConfig);
551
+ const registered = await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
595
552
  if (!registered) {
596
553
  console.error(chalk.red(`\n ❌ FATAL: Could not register with broker at ${BROKER_URL}`));
597
554
  process.exit(1);
@@ -599,11 +556,9 @@ export async function startHostMode() {
599
556
  });
600
557
 
601
558
  // Wait for the first URL to be generated before showing the dashboard
602
- while (!tunnelUrl) {
603
- await new Promise(r => setTimeout(r, 100));
604
- }
559
+ await waitForValue(() => tunnelUrl, 30000, 'Cloudflare tunnel startup');
605
560
 
606
561
  setRevokeOnExit(uid, BROKER_URL);
607
562
 
608
563
  await hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProcess);
609
- }
564
+ }
package/src/server.js CHANGED
@@ -137,8 +137,13 @@ app.post('/register', strictLimiter, (req, res) => {
137
137
  return res.status(400).json({ error: 'Invalid IV format (expected 32 hex chars)' });
138
138
  }
139
139
 
140
+ if (!/^[a-f0-9]{32}$/i.test(salt)) {
141
+ recordViolation(req);
142
+ return res.status(400).json({ error: 'Invalid salt format (expected 32 hex chars)' });
143
+ }
144
+
140
145
  // Validate ciphertext is non-empty base64
141
- if (typeof ciphertext !== 'string' || ciphertext.length === 0) {
146
+ if (typeof ciphertext !== 'string' || ciphertext.length === 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(ciphertext)) {
142
147
  recordViolation(req);
143
148
  return res.status(400).json({ error: 'Invalid ciphertext' });
144
149
  }