@miraj181/ipingyou 2.0.10 → 2.0.13

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,78 +144,12 @@ 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
- const tmpDir = os.tmpdir();
149
+ const tmpDir = os.tmpdir() || process.env.TMPDIR || process.env.TEMP || process.env.TMP;
150
+ if (!tmpDir) {
151
+ throw new Error('Could not resolve a temporary directory for SSH key generation');
152
+ }
206
153
  const keyPath = path.join(tmpDir, `ipingyou_${Date.now()}`);
207
154
 
208
155
  await execa('ssh-keygen', ['-t', 'ed25519', '-C', 'ipingyou-ephemeral', '-f', keyPath, '-N', '']);
@@ -214,8 +161,12 @@ async function generateEphemeralKey() {
214
161
  }
215
162
 
216
163
  async function injectPublicKey(pubKey) {
217
- const osInfo = detectOS();
218
- const sshDir = path.join(osInfo.homedir, '.ssh');
164
+ const homedir = os.homedir();
165
+ if (!homedir) {
166
+ throw new Error('Could not resolve the current user home directory for authorized_keys');
167
+ }
168
+
169
+ const sshDir = path.join(homedir, '.ssh');
219
170
 
220
171
  if (!fs.existsSync(sshDir)) {
221
172
  await fs.promises.mkdir(sshDir, { mode: 0o700, recursive: true });
@@ -234,21 +185,6 @@ async function removePublicKey(authKeysPath, pubKey) {
234
185
  }
235
186
  }
236
187
 
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
188
  /**
253
189
  * Auto-spawn a Private Broker locally and wrap it in a Cloudflare tunnel.
254
190
  */
@@ -258,19 +194,32 @@ async function spawnPrivateBroker() {
258
194
  // 1. Spawn the broker server process
259
195
  const brokerProcess = execa('node', [path.join(__dirname, '../server.js')], {
260
196
  env: { ...process.env, PORT: '4040' },
261
- reject: false
197
+ reject: false,
198
+ all: true,
262
199
  });
263
200
  trackPID(brokerProcess.pid);
264
201
 
202
+ let brokerExited = false;
203
+ let brokerOutput = '';
204
+ brokerProcess.all?.on('data', chunk => {
205
+ brokerOutput += chunk.toString();
206
+ });
207
+ brokerProcess.on('exit', () => {
208
+ brokerExited = true;
209
+ });
210
+
265
211
  // 2. Wrap it in a cloudflare tunnel
266
212
  let brokerTunnelUrl = null;
267
213
  const privateBrokerTunnelProcess = await spawnTunnelSupervised('http://localhost:4040', (newUrl) => {
268
214
  brokerTunnelUrl = newUrl;
269
215
  });
270
216
 
271
- while (!brokerTunnelUrl) {
272
- await new Promise(r => setTimeout(r, 100));
273
- }
217
+ await waitForValue(() => {
218
+ if (brokerExited) {
219
+ throw new Error(`Private broker exited before tunnel was ready${brokerOutput ? `: ${brokerOutput.trim()}` : ''}`);
220
+ }
221
+ return brokerTunnelUrl;
222
+ }, 30000, 'Private broker tunnel startup');
274
223
 
275
224
  console.log(chalk.green(` ✅ Private Broker Active: ${chalk.bold.cyan(brokerTunnelUrl)}\n`));
276
225
 
@@ -278,53 +227,11 @@ async function spawnPrivateBroker() {
278
227
  url: brokerTunnelUrl,
279
228
  kill: () => {
280
229
  privateBrokerTunnelProcess.kill();
281
- untrackPID(brokerProcess.pid);
282
- try { process.kill(brokerProcess.pid); } catch { /* ignore */ }
230
+ killProcessTree(brokerProcess.pid).finally(() => untrackPID(brokerProcess.pid));
283
231
  }
284
232
  };
285
233
  }
286
234
 
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
235
  // Monitor active connections removed (replaced by Telemetry)
329
236
 
330
237
  /**
@@ -397,7 +304,7 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
397
304
  chatTunnelProcess = null;
398
305
  chatServerInstance = null;
399
306
  delete serviceConfig.chatUrl;
400
- await registerWithBroker(uid, tunnelUrl, password, serviceConfig);
307
+ await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
401
308
  renderDashboard();
402
309
  }
403
310
  });
@@ -405,13 +312,11 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
405
312
  console.log(chalk.dim(' Provisioning Cloudflare tunnel for chat...'));
406
313
  chatTunnelProcess = await spawnTunnelSupervised(`http://localhost:${chatServerInstance.port}`, async (newUrl) => {
407
314
  serviceConfig.chatUrl = newUrl;
408
- await registerWithBroker(uid, tunnelUrl, password, serviceConfig);
315
+ await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
409
316
  renderDashboard();
410
317
  });
411
318
 
412
- while (!serviceConfig.chatUrl) {
413
- await new Promise(r => setTimeout(r, 100));
414
- }
319
+ await waitForValue(() => serviceConfig.chatUrl, 30000, 'Chat tunnel startup');
415
320
 
416
321
  console.log(chalk.green(' ✅ Chat Room Live! Clients can now join.'));
417
322
  await openLocalChatUI(chatServerInstance.port, password);
@@ -427,14 +332,24 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
427
332
  console.log('');
428
333
  console.log(chalk.bold.cyan(' 📺 Terminal Mirroring'));
429
334
  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.'));
335
+ console.log(chalk.dim(' Attaching to the tmux session created by an interactive SSH client.'));
336
+ console.log(chalk.dim(' Press Ctrl+b then d to detach gracefully.'));
432
337
  console.log('');
433
338
 
434
339
  try {
340
+ await execaCommand('tmux -V', { reject: true });
341
+ const sessionCheck = await execa('tmux', ['has-session', '-t', 'SecureLink_Session'], { reject: false });
342
+ if (sessionCheck.exitCode !== 0) {
343
+ console.log(chalk.yellow(' ⚠️ No mirrored terminal session is active yet.'));
344
+ console.log(chalk.dim(' A client must choose "Connect via SSH" first. SCP-only clients do not create a tmux session.'));
345
+ console.log(chalk.dim(' tmux is needed on the host machine only; the client does not need tmux.'));
346
+ return waitForAction();
347
+ }
435
348
  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.'));
349
+ } catch (err) {
350
+ console.log(chalk.yellow(' ⚠️ Could not attach to tmux.'));
351
+ console.log(chalk.dim(` ${err.message}`));
352
+ console.log(chalk.dim(' Terminal mirroring requires tmux on the host machine and an active interactive SSH client.'));
438
353
  }
439
354
  return waitForAction();
440
355
  }
@@ -476,16 +391,17 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
476
391
  }
477
392
 
478
393
  case 'reregister':
479
- await registerWithBroker(uid, tunnelUrl, password, serviceConfig);
394
+ await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
480
395
  return waitForAction();
481
396
 
482
397
  case 'terminate': {
483
398
  const spinner = createSpinner('Terminating active SSH sessions...', networkSpinner).start();
484
399
  try {
485
400
  if (process.platform === 'win32') {
486
- await execaCommand('taskkill /F /IM sshd.exe', { reject: false });
401
+ 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
402
  } else {
488
403
  await execaCommand("pkill -f 'sshd:.*@'", { shell: true, reject: false });
404
+ await execaCommand('tmux kill-session -t SecureLink_Session', { reject: false });
489
405
  }
490
406
  spinner.succeed('All client SSH sessions terminated');
491
407
  } catch {
@@ -495,9 +411,10 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
495
411
  }
496
412
 
497
413
  case 'exit':
498
- if (tunnelProcess) tunnelProcess.kill();
499
414
  if (chatTunnelProcess) chatTunnelProcess.kill();
500
415
  if (global.privateBrokerInstance) global.privateBrokerInstance.kill();
416
+ if (tunnelProcess) tunnelProcess.kill();
417
+ await cleanupAll();
501
418
  return;
502
419
  }
503
420
  } catch (err) {
@@ -627,7 +544,7 @@ export async function startHostMode() {
627
544
  });
628
545
  console.log(chalk.green(' ✓ Ephemeral key injected. Client will connect without system password!'));
629
546
  } catch (err) {
630
- console.log(chalk.yellow(` ⚠️ Could not generate ephemeral key: ${err.message}`));
547
+ console.log(chalk.yellow(` ⚠️ Could not prepare ephemeral SSH key: ${err.message}`));
631
548
  console.log(chalk.dim(' Client will need to use standard OS password.'));
632
549
  }
633
550
  } else {
@@ -638,7 +555,7 @@ export async function startHostMode() {
638
555
  const tunnelProcess = await spawnTunnelSupervised(targetUrl, async (newUrl) => {
639
556
  tunnelUrl = newUrl;
640
557
  // Register or re-register with broker when tunnel is spawned/respawned
641
- const registered = await registerWithBroker(uid, tunnelUrl, password, serviceConfig);
558
+ const registered = await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
642
559
  if (!registered) {
643
560
  console.error(chalk.red(`\n ❌ FATAL: Could not register with broker at ${BROKER_URL}`));
644
561
  process.exit(1);
@@ -646,11 +563,9 @@ export async function startHostMode() {
646
563
  });
647
564
 
648
565
  // Wait for the first URL to be generated before showing the dashboard
649
- while (!tunnelUrl) {
650
- await new Promise(r => setTimeout(r, 100));
651
- }
566
+ await waitForValue(() => tunnelUrl, 30000, 'Cloudflare tunnel startup');
652
567
 
653
568
  setRevokeOnExit(uid, BROKER_URL);
654
569
 
655
570
  await hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProcess);
656
- }
571
+ }
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
  }