@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.
package/src/modes/host.js CHANGED
@@ -21,15 +21,28 @@ import { fileURLToPath } from 'node:url';
21
21
  import fs from 'node:fs';
22
22
  import os from 'node:os';
23
23
  import { generateUID } from '../lib/uid.js';
24
- import { encrypt, decrypt } from '../lib/crypto.js';
25
- 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';
26
26
  import { detectOS } from '../lib/platform.js';
27
- import { createSpinner, cryptoSpinner, tunnelSpinner, networkSpinner, typeText } from '../lib/animations.js';
27
+ import { createSpinner, networkSpinner, typeText } from '../lib/animations.js';
28
28
  import { startChatServer, openLocalChatUI } from '../lib/chat.js';
29
+ import { spawnTunnelSupervised } from '../lib/tunnel.js';
30
+ import { pingBroker, registerWithBroker } from '../lib/broker.js';
29
31
 
30
32
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
33
  let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
32
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
+
33
46
  /**
34
47
  * Ensure the local SSH server is running.
35
48
  */
@@ -131,75 +144,6 @@ async function ensureTmuxInstalled() {
131
144
  }
132
145
  }
133
146
 
134
- /**
135
- * Supervise cloudflared tunnel, restarting it if it crashes.
136
- */
137
- async function spawnTunnelSupervised(targetUrl, onUrlGenerated) {
138
- let isShuttingDown = false;
139
- let activeChild = null;
140
-
141
- const loop = async () => {
142
- while (!isShuttingDown) {
143
- const spinner = createSpinner('Starting Cloudflare tunnel...', tunnelSpinner).start();
144
-
145
- await new Promise((resolve) => {
146
- activeChild = execa('cloudflared', ['tunnel', '--url', targetUrl], {
147
- reject: false,
148
- all: true,
149
- });
150
-
151
- trackPID(activeChild.pid);
152
- let tunnelUrl = null;
153
- let resolved = false;
154
-
155
- activeChild.all.on('data', (chunk) => {
156
- const text = chunk.toString();
157
- const match = text.match(/https:\/\/[-0-9a-z]+\.trycloudflare\.com/);
158
- if (match && !resolved) {
159
- tunnelUrl = match[0];
160
- resolved = true;
161
- spinner.succeed(`Tunnel active: ${chalk.cyan(tunnelUrl)}`);
162
- onUrlGenerated(tunnelUrl);
163
- }
164
- });
165
-
166
- activeChild.on('exit', (code) => {
167
- untrackPID(activeChild.pid);
168
- if (!resolved) {
169
- spinner.fail('Cloudflare tunnel exited before generating URL');
170
- } else if (!isShuttingDown) {
171
- console.log(chalk.yellow(`\n ⚠️ Tunnel disconnected (code ${code}). Restarting...`));
172
- }
173
- resolve(); // Let the loop continue to restart
174
- });
175
-
176
- activeChild.on('error', (err) => {
177
- untrackPID(activeChild.pid);
178
- spinner.fail(`Tunnel error: ${err.message}`);
179
- resolve();
180
- });
181
- });
182
-
183
- if (!isShuttingDown) {
184
- // Wait a bit before restarting to avoid tight loop
185
- await new Promise(r => setTimeout(r, 2000));
186
- }
187
- }
188
- };
189
-
190
- loop(); // Fire and forget the supervisor loop
191
-
192
- return {
193
- kill: () => {
194
- isShuttingDown = true;
195
- if (activeChild) {
196
- untrackPID(activeChild.pid);
197
- try { process.kill(activeChild.pid); } catch { /* ignore */ }
198
- }
199
- }
200
- };
201
- }
202
-
203
147
  // ─── Ephemeral SSH Key Management ────────────────────────────
204
148
  async function generateEphemeralKey() {
205
149
  const tmpDir = os.tmpdir();
@@ -234,21 +178,6 @@ async function removePublicKey(authKeysPath, pubKey) {
234
178
  }
235
179
  }
236
180
 
237
- /**
238
- * Ping the broker to see if it's online.
239
- */
240
- async function pingBroker(url) {
241
- try {
242
- const controller = new AbortController();
243
- const id = setTimeout(() => controller.abort(), 3000);
244
- const res = await fetch(`${url}/health`, { signal: controller.signal });
245
- clearTimeout(id);
246
- return res.ok;
247
- } catch {
248
- return false;
249
- }
250
- }
251
-
252
181
  /**
253
182
  * Auto-spawn a Private Broker locally and wrap it in a Cloudflare tunnel.
254
183
  */
@@ -258,19 +187,32 @@ async function spawnPrivateBroker() {
258
187
  // 1. Spawn the broker server process
259
188
  const brokerProcess = execa('node', [path.join(__dirname, '../server.js')], {
260
189
  env: { ...process.env, PORT: '4040' },
261
- reject: false
190
+ reject: false,
191
+ all: true,
262
192
  });
263
193
  trackPID(brokerProcess.pid);
264
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
+
265
204
  // 2. Wrap it in a cloudflare tunnel
266
205
  let brokerTunnelUrl = null;
267
206
  const privateBrokerTunnelProcess = await spawnTunnelSupervised('http://localhost:4040', (newUrl) => {
268
207
  brokerTunnelUrl = newUrl;
269
208
  });
270
209
 
271
- while (!brokerTunnelUrl) {
272
- await new Promise(r => setTimeout(r, 100));
273
- }
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');
274
216
 
275
217
  console.log(chalk.green(` ✅ Private Broker Active: ${chalk.bold.cyan(brokerTunnelUrl)}\n`));
276
218
 
@@ -278,53 +220,11 @@ async function spawnPrivateBroker() {
278
220
  url: brokerTunnelUrl,
279
221
  kill: () => {
280
222
  privateBrokerTunnelProcess.kill();
281
- untrackPID(brokerProcess.pid);
282
- try { process.kill(brokerProcess.pid); } catch { /* ignore */ }
223
+ killProcessTree(brokerProcess.pid).finally(() => untrackPID(brokerProcess.pid));
283
224
  }
284
225
  };
285
226
  }
286
227
 
287
- /**
288
- * Encrypt tunnel details and register with the Central Broker.
289
- */
290
- async function registerWithBroker(uid, tunnelUrl, password, serviceConfig) {
291
- const spinner = createSpinner('Encrypting session data...', cryptoSpinner).start();
292
-
293
- try {
294
- // Encrypt the JSON payload LOCALLY before sending
295
- await new Promise(r => setTimeout(r, 600)); // animation effect
296
- const payload = JSON.stringify({ url: tunnelUrl, ...serviceConfig });
297
- const encrypted = encrypt(payload, password);
298
-
299
- spinner.text = 'Registering with broker...';
300
-
301
- const res = await fetch(`${BROKER_URL}/register`, {
302
- method: 'POST',
303
- headers: { 'Content-Type': 'application/json' },
304
- body: JSON.stringify({
305
- uid,
306
- iv: encrypted.iv,
307
- ciphertext: encrypted.ciphertext,
308
- salt: encrypted.salt,
309
- }),
310
- });
311
-
312
- if (!res.ok) {
313
- const data = await res.json().catch(() => ({}));
314
- throw new Error(data.error || `HTTP ${res.status}`);
315
- }
316
-
317
- spinner.succeed(`Registered with broker ${chalk.dim(`(${BROKER_URL})`)} ${chalk.green('[E2E encrypted]')}`);
318
- return true;
319
- } catch (err) {
320
- spinner.fail(`Broker registration failed: ${err.message}`);
321
- console.error(chalk.red(` ❌ Error: ${err.message}`));
322
- console.log(chalk.yellow(' ⚠️ Remote clients won\'t be able to find you without the broker.'));
323
- console.log(chalk.dim(' Share the tunnel URL directly if needed.'));
324
- return false;
325
- }
326
- }
327
-
328
228
  // Monitor active connections removed (replaced by Telemetry)
329
229
 
330
230
  /**
@@ -397,7 +297,7 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
397
297
  chatTunnelProcess = null;
398
298
  chatServerInstance = null;
399
299
  delete serviceConfig.chatUrl;
400
- await registerWithBroker(uid, tunnelUrl, password, serviceConfig);
300
+ await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
401
301
  renderDashboard();
402
302
  }
403
303
  });
@@ -405,13 +305,11 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
405
305
  console.log(chalk.dim(' Provisioning Cloudflare tunnel for chat...'));
406
306
  chatTunnelProcess = await spawnTunnelSupervised(`http://localhost:${chatServerInstance.port}`, async (newUrl) => {
407
307
  serviceConfig.chatUrl = newUrl;
408
- await registerWithBroker(uid, tunnelUrl, password, serviceConfig);
308
+ await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
409
309
  renderDashboard();
410
310
  });
411
311
 
412
- while (!serviceConfig.chatUrl) {
413
- await new Promise(r => setTimeout(r, 100));
414
- }
312
+ await waitForValue(() => serviceConfig.chatUrl, 30000, 'Chat tunnel startup');
415
313
 
416
314
  console.log(chalk.green(' ✅ Chat Room Live! Clients can now join.'));
417
315
  await openLocalChatUI(chatServerInstance.port, password);
@@ -427,14 +325,24 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
427
325
  console.log('');
428
326
  console.log(chalk.bold.cyan(' 📺 Terminal Mirroring'));
429
327
  console.log(chalk.dim(' ──────────────────────────────────────'));
430
- console.log(chalk.dim(' Attaching to client session. Press Ctrl+b then d to detach gracefully!'));
431
- 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.'));
432
330
  console.log('');
433
331
 
434
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
+ }
435
341
  await execaCommand('tmux attach -t SecureLink_Session -r', { stdio: 'inherit' });
436
- } catch {
437
- 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.'));
438
346
  }
439
347
  return waitForAction();
440
348
  }
@@ -476,16 +384,17 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
476
384
  }
477
385
 
478
386
  case 'reregister':
479
- await registerWithBroker(uid, tunnelUrl, password, serviceConfig);
387
+ await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
480
388
  return waitForAction();
481
389
 
482
390
  case 'terminate': {
483
391
  const spinner = createSpinner('Terminating active SSH sessions...', networkSpinner).start();
484
392
  try {
485
393
  if (process.platform === 'win32') {
486
- 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 });
487
395
  } else {
488
396
  await execaCommand("pkill -f 'sshd:.*@'", { shell: true, reject: false });
397
+ await execaCommand('tmux kill-session -t SecureLink_Session', { reject: false });
489
398
  }
490
399
  spinner.succeed('All client SSH sessions terminated');
491
400
  } catch {
@@ -495,9 +404,10 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
495
404
  }
496
405
 
497
406
  case 'exit':
498
- if (tunnelProcess) tunnelProcess.kill();
499
407
  if (chatTunnelProcess) chatTunnelProcess.kill();
500
408
  if (global.privateBrokerInstance) global.privateBrokerInstance.kill();
409
+ if (tunnelProcess) tunnelProcess.kill();
410
+ await cleanupAll();
501
411
  return;
502
412
  }
503
413
  } catch (err) {
@@ -638,7 +548,7 @@ export async function startHostMode() {
638
548
  const tunnelProcess = await spawnTunnelSupervised(targetUrl, async (newUrl) => {
639
549
  tunnelUrl = newUrl;
640
550
  // Register or re-register with broker when tunnel is spawned/respawned
641
- const registered = await registerWithBroker(uid, tunnelUrl, password, serviceConfig);
551
+ const registered = await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
642
552
  if (!registered) {
643
553
  console.error(chalk.red(`\n ❌ FATAL: Could not register with broker at ${BROKER_URL}`));
644
554
  process.exit(1);
@@ -646,11 +556,9 @@ export async function startHostMode() {
646
556
  });
647
557
 
648
558
  // Wait for the first URL to be generated before showing the dashboard
649
- while (!tunnelUrl) {
650
- await new Promise(r => setTimeout(r, 100));
651
- }
559
+ await waitForValue(() => tunnelUrl, 30000, 'Cloudflare tunnel startup');
652
560
 
653
561
  setRevokeOnExit(uid, BROKER_URL);
654
562
 
655
563
  await hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProcess);
656
- }
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
  }