@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/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 +52 -0
- package/src/lib/tunnel.js +66 -0
- package/src/modes/client.js +48 -241
- package/src/modes/host.js +70 -155
- 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,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
|
|
218
|
-
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
|
431
|
-
console.log(chalk.dim('
|
|
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
|
|
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('
|
|
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
|
|
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
|
-
|
|
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
|
}
|