@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/package.json +1 -1
- package/src/cli.js +10 -15
- package/src/lib/broker.js +144 -0
- package/src/lib/checksum.js +12 -0
- package/src/lib/cleanup.js +37 -15
- package/src/lib/config.js +7 -3
- package/src/lib/path-browser.js +232 -0
- package/src/lib/ssh.js +55 -0
- package/src/lib/tunnel.js +66 -0
- package/src/modes/client.js +48 -241
- package/src/modes/host.js +59 -151
- package/src/server.js +6 -1
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 {
|
|
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,
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
|
431
|
-
console.log(chalk.dim('
|
|
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
|
|
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('
|
|
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
|
-
|
|
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
|
}
|