@miraj181/ipingyou 1.0.1 → 2.0.0
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/README.md +36 -96
- package/package.json +11 -4
- package/src/cli.js +84 -4
- package/src/lib/chat.js +333 -0
- package/src/lib/cleanup.js +78 -0
- package/src/lib/config.js +41 -0
- package/src/lib/crypto.js +23 -14
- package/src/lib/platform.js +415 -53
- package/src/modes/client.js +451 -58
- package/src/modes/host.js +416 -119
- package/src/server.js +0 -143
package/src/modes/host.js
CHANGED
|
@@ -16,13 +16,18 @@
|
|
|
16
16
|
import { execa, execaCommand } from 'execa';
|
|
17
17
|
import chalk from 'chalk';
|
|
18
18
|
import inquirer from 'inquirer';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
19
21
|
import { generateUID } from '../lib/uid.js';
|
|
20
|
-
import { encrypt } from '../lib/crypto.js';
|
|
21
|
-
import { trackPID, untrackPID, setRevokeOnExit } from '../lib/cleanup.js';
|
|
22
|
+
import { encrypt, decrypt } from '../lib/crypto.js';
|
|
23
|
+
import { trackPID, untrackPID, setRevokeOnExit, addCleanupHook } from '../lib/cleanup.js';
|
|
22
24
|
import { detectOS } from '../lib/platform.js';
|
|
23
25
|
import { createSpinner, cryptoSpinner, tunnelSpinner, networkSpinner, typeText } from '../lib/animations.js';
|
|
26
|
+
import { startChatServer, openLocalChatUI } from '../lib/chat.js';
|
|
27
|
+
import open from 'open';
|
|
24
28
|
|
|
25
|
-
const
|
|
29
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
|
|
26
31
|
|
|
27
32
|
/**
|
|
28
33
|
* Ensure the local SSH server is running.
|
|
@@ -76,73 +81,176 @@ async function ensureSSHRunning() {
|
|
|
76
81
|
}
|
|
77
82
|
}
|
|
78
83
|
} catch (err) {
|
|
79
|
-
spinner.fail(`
|
|
80
|
-
console.log(chalk.dim(' Continue anyway? The tunnel will still start, but
|
|
84
|
+
spinner.fail(`Service check failed: ${err.message}`);
|
|
85
|
+
console.log(chalk.dim(' Continue anyway? The tunnel will still start, but connections may fail.'));
|
|
81
86
|
}
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
/**
|
|
85
|
-
*
|
|
90
|
+
* Supervise cloudflared tunnel, restarting it if it crashes.
|
|
86
91
|
*/
|
|
87
|
-
async function
|
|
88
|
-
|
|
92
|
+
async function spawnTunnelSupervised(targetUrl, onUrlGenerated) {
|
|
93
|
+
let isShuttingDown = false;
|
|
94
|
+
let activeChild = null;
|
|
95
|
+
|
|
96
|
+
const loop = async () => {
|
|
97
|
+
while (!isShuttingDown) {
|
|
98
|
+
const spinner = createSpinner('Starting Cloudflare tunnel...', tunnelSpinner).start();
|
|
99
|
+
|
|
100
|
+
await new Promise((resolve) => {
|
|
101
|
+
activeChild = execa('cloudflared', ['tunnel', '--url', targetUrl], {
|
|
102
|
+
reject: false,
|
|
103
|
+
all: true,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
trackPID(activeChild.pid);
|
|
107
|
+
let tunnelUrl = null;
|
|
108
|
+
let resolved = false;
|
|
109
|
+
|
|
110
|
+
activeChild.all.on('data', (chunk) => {
|
|
111
|
+
const text = chunk.toString();
|
|
112
|
+
const match = text.match(/https:\/\/[-0-9a-z]+\.trycloudflare\.com/);
|
|
113
|
+
if (match && !resolved) {
|
|
114
|
+
tunnelUrl = match[0];
|
|
115
|
+
resolved = true;
|
|
116
|
+
spinner.succeed(`Tunnel active: ${chalk.cyan(tunnelUrl)}`);
|
|
117
|
+
onUrlGenerated(tunnelUrl);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
activeChild.on('exit', (code) => {
|
|
122
|
+
untrackPID(activeChild.pid);
|
|
123
|
+
if (!resolved) {
|
|
124
|
+
spinner.fail('Cloudflare tunnel exited before generating URL');
|
|
125
|
+
} else if (!isShuttingDown) {
|
|
126
|
+
console.log(chalk.yellow(`\n ⚠️ Tunnel disconnected (code ${code}). Restarting...`));
|
|
127
|
+
}
|
|
128
|
+
resolve(); // Let the loop continue to restart
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
activeChild.on('error', (err) => {
|
|
132
|
+
untrackPID(activeChild.pid);
|
|
133
|
+
spinner.fail(`Tunnel error: ${err.message}`);
|
|
134
|
+
resolve();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (!isShuttingDown) {
|
|
139
|
+
// Wait a bit before restarting to avoid tight loop
|
|
140
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
89
144
|
|
|
90
|
-
|
|
91
|
-
const child = execa('cloudflared', ['tunnel', '--url', 'ssh://localhost:22'], {
|
|
92
|
-
reject: false,
|
|
93
|
-
all: true,
|
|
94
|
-
});
|
|
145
|
+
loop(); // Fire and forget the supervisor loop
|
|
95
146
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const match = text.match(/https:\/\/[-0-9a-z]+\.trycloudflare\.com/);
|
|
103
|
-
if (match && !resolved) {
|
|
104
|
-
tunnelUrl = match[0];
|
|
105
|
-
resolved = true;
|
|
106
|
-
spinner.succeed(`Tunnel active: ${chalk.cyan(tunnelUrl)}`);
|
|
107
|
-
resolve({ process: child, url: tunnelUrl });
|
|
147
|
+
return {
|
|
148
|
+
kill: () => {
|
|
149
|
+
isShuttingDown = true;
|
|
150
|
+
if (activeChild) {
|
|
151
|
+
untrackPID(activeChild.pid);
|
|
152
|
+
try { process.kill(activeChild.pid); } catch { /* ignore */ }
|
|
108
153
|
}
|
|
109
|
-
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
110
157
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
158
|
+
// ─── Ephemeral SSH Key Management ────────────────────────────
|
|
159
|
+
async function generateEphemeralKey() {
|
|
160
|
+
const osInfo = detectOS();
|
|
161
|
+
const tmpDir = osInfo.isWindows ? process.env.TEMP : '/tmp';
|
|
162
|
+
const keyPath = path.join(tmpDir, `ipingyou_${Date.now()}`);
|
|
163
|
+
|
|
164
|
+
await execa('ssh-keygen', ['-t', 'ed25519', '-C', 'ipingyou-ephemeral', '-f', keyPath, '-N', '']);
|
|
165
|
+
|
|
166
|
+
const privKey = (await fs.promises.readFile(keyPath, 'utf8')).trim();
|
|
167
|
+
const pubKey = (await fs.promises.readFile(`${keyPath}.pub`, 'utf8')).trim();
|
|
168
|
+
|
|
169
|
+
return { keyPath, privKey, pubKey };
|
|
170
|
+
}
|
|
118
171
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
172
|
+
async function injectPublicKey(pubKey) {
|
|
173
|
+
const osInfo = detectOS();
|
|
174
|
+
const sshDir = path.join(osInfo.homedir, '.ssh');
|
|
175
|
+
|
|
176
|
+
if (!fs.existsSync(sshDir)) {
|
|
177
|
+
await fs.promises.mkdir(sshDir, { mode: 0o700, recursive: true });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const authKeysPath = path.join(sshDir, 'authorized_keys');
|
|
181
|
+
await fs.promises.appendFile(authKeysPath, `\n${pubKey}\n`);
|
|
182
|
+
return authKeysPath;
|
|
183
|
+
}
|
|
126
184
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
185
|
+
async function removePublicKey(authKeysPath, pubKey) {
|
|
186
|
+
if (fs.existsSync(authKeysPath)) {
|
|
187
|
+
let keys = await fs.promises.readFile(authKeysPath, 'utf8');
|
|
188
|
+
keys = keys.replace(`\n${pubKey}\n`, '');
|
|
189
|
+
await fs.promises.writeFile(authKeysPath, keys);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Ping the broker to see if it's online.
|
|
195
|
+
*/
|
|
196
|
+
async function pingBroker(url) {
|
|
197
|
+
try {
|
|
198
|
+
const controller = new AbortController();
|
|
199
|
+
const id = setTimeout(() => controller.abort(), 3000);
|
|
200
|
+
const res = await fetch(`${url}/health`, { signal: controller.signal });
|
|
201
|
+
clearTimeout(id);
|
|
202
|
+
return res.ok;
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Auto-spawn a Private Broker locally and wrap it in a Cloudflare tunnel.
|
|
210
|
+
*/
|
|
211
|
+
async function spawnPrivateBroker() {
|
|
212
|
+
console.log(chalk.yellow('\n ⚠️ Public Broker is unreachable. Spawning Private Broker...'));
|
|
213
|
+
|
|
214
|
+
// 1. Spawn the broker server process
|
|
215
|
+
const brokerProcess = execa('node', [path.join(__dirname, '../server.js')], {
|
|
216
|
+
env: { ...process.env, PORT: '4040' },
|
|
217
|
+
reject: false
|
|
133
218
|
});
|
|
219
|
+
trackPID(brokerProcess.pid);
|
|
220
|
+
|
|
221
|
+
// 2. Wrap it in a cloudflare tunnel
|
|
222
|
+
let brokerTunnelUrl = null;
|
|
223
|
+
const privateBrokerTunnelProcess = await spawnTunnelSupervised('http://localhost:4040', (newUrl) => {
|
|
224
|
+
brokerTunnelUrl = newUrl;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
while (!brokerTunnelUrl) {
|
|
228
|
+
await new Promise(r => setTimeout(r, 100));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
console.log(chalk.green(` ✅ Private Broker Active: ${chalk.bold.cyan(brokerTunnelUrl)}\n`));
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
url: brokerTunnelUrl,
|
|
235
|
+
kill: () => {
|
|
236
|
+
privateBrokerTunnelProcess.kill();
|
|
237
|
+
untrackPID(brokerProcess.pid);
|
|
238
|
+
try { process.kill(brokerProcess.pid); } catch { /* ignore */ }
|
|
239
|
+
}
|
|
240
|
+
};
|
|
134
241
|
}
|
|
135
242
|
|
|
136
243
|
/**
|
|
137
|
-
* Encrypt tunnel
|
|
244
|
+
* Encrypt tunnel details and register with the Central Broker.
|
|
138
245
|
*/
|
|
139
|
-
async function registerWithBroker(uid, tunnelUrl) {
|
|
140
|
-
const spinner = createSpinner('Encrypting
|
|
246
|
+
async function registerWithBroker(uid, tunnelUrl, password, serviceConfig) {
|
|
247
|
+
const spinner = createSpinner('Encrypting session data...', cryptoSpinner).start();
|
|
141
248
|
|
|
142
249
|
try {
|
|
143
|
-
// Encrypt the
|
|
250
|
+
// Encrypt the JSON payload LOCALLY before sending
|
|
144
251
|
await new Promise(r => setTimeout(r, 600)); // animation effect
|
|
145
|
-
const
|
|
252
|
+
const payload = JSON.stringify({ url: tunnelUrl, ...serviceConfig });
|
|
253
|
+
const encrypted = encrypt(payload, password);
|
|
146
254
|
|
|
147
255
|
spinner.text = 'Registering with broker...';
|
|
148
256
|
|
|
@@ -153,6 +261,7 @@ async function registerWithBroker(uid, tunnelUrl) {
|
|
|
153
261
|
uid,
|
|
154
262
|
iv: encrypted.iv,
|
|
155
263
|
ciphertext: encrypted.ciphertext,
|
|
264
|
+
salt: encrypted.salt,
|
|
156
265
|
}),
|
|
157
266
|
});
|
|
158
267
|
|
|
@@ -172,83 +281,158 @@ async function registerWithBroker(uid, tunnelUrl) {
|
|
|
172
281
|
}
|
|
173
282
|
}
|
|
174
283
|
|
|
175
|
-
|
|
176
|
-
* Monitor active connections to port 22.
|
|
177
|
-
*/
|
|
178
|
-
async function getConnectedIPs() {
|
|
179
|
-
const osInfo = detectOS();
|
|
180
|
-
try {
|
|
181
|
-
let cmd;
|
|
182
|
-
if (osInfo.isWindows) {
|
|
183
|
-
cmd = 'netstat -an | findstr :22 | findstr ESTABLISHED';
|
|
184
|
-
} else {
|
|
185
|
-
cmd = "ss -tn sport = :22 | grep ESTAB | awk '{print $5}' | cut -d: -f1";
|
|
186
|
-
}
|
|
187
|
-
const { stdout } = await execaCommand(cmd, { shell: true, reject: false });
|
|
188
|
-
return stdout.split('\n').filter(Boolean).map(ip => ip.trim());
|
|
189
|
-
} catch {
|
|
190
|
-
return [];
|
|
191
|
-
}
|
|
192
|
-
}
|
|
284
|
+
// Monitor active connections removed (replaced by Telemetry)
|
|
193
285
|
|
|
194
286
|
/**
|
|
195
287
|
* Display the host dashboard and handle user input.
|
|
196
288
|
*/
|
|
197
|
-
async function hostDashboard(uid, tunnelUrl) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const monitorInterval = setInterval(async () => {
|
|
215
|
-
const ips = await getConnectedIPs();
|
|
216
|
-
if (ips.length > 0) {
|
|
217
|
-
console.log(chalk.cyan(` 📡 Active connections (${ips.length}):`));
|
|
218
|
-
ips.forEach(ip => console.log(chalk.dim(` → ${ip}`)));
|
|
289
|
+
async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProcess) {
|
|
290
|
+
let chatServerInstance = null;
|
|
291
|
+
let chatTunnelProcess = null;
|
|
292
|
+
|
|
293
|
+
const renderDashboard = () => {
|
|
294
|
+
console.clear();
|
|
295
|
+
console.log('');
|
|
296
|
+
console.log(chalk.bold(' ╔════════════════════════════════════════════════════╗'));
|
|
297
|
+
console.log(chalk.bold(' ║ 🛡️ SecureLink — HOST MODE ACTIVE ║'));
|
|
298
|
+
console.log(chalk.bold(' ╠════════════════════════════════════════════════════╣'));
|
|
299
|
+
console.log(` ║ ${chalk.cyan('UID:')} ${chalk.bold.white(uid.padEnd(30))}║`);
|
|
300
|
+
console.log(` ║ ${chalk.cyan('Password:')} ${chalk.bold.white(password.padEnd(30))}║`);
|
|
301
|
+
console.log(` ║ ${chalk.cyan('Service:')} ${chalk.dim(serviceConfig.type.toUpperCase() + ' (Port ' + serviceConfig.port + ')').padEnd(30)}║`);
|
|
302
|
+
console.log(` ║ ${chalk.cyan('Tunnel:')} ${chalk.dim(tunnelUrl.substring(0, 40))} ║`);
|
|
303
|
+
if (serviceConfig.chatUrl) {
|
|
304
|
+
console.log(` ║ ${chalk.cyan('Chat URL:')} ${chalk.dim(serviceConfig.chatUrl.substring(0, 40))} ║`);
|
|
219
305
|
}
|
|
220
|
-
},
|
|
306
|
+
console.log(` ║ ${chalk.cyan('Broker:')} ${chalk.dim(BROKER_URL.substring(0, 40))} ║`);
|
|
307
|
+
console.log(` ║ ${chalk.cyan('Crypto:')} ${chalk.green('AES-256-CBC E2E (PBKDF2)')} ║`);
|
|
308
|
+
console.log(chalk.bold(' ╠════════════════════════════════════════════════════╣'));
|
|
309
|
+
console.log(` ║ ${chalk.yellow('Share the UID, Password & Broker URL with client ')} ║`);
|
|
310
|
+
console.log(` ║ ${chalk.dim('Press Ctrl+C to terminate the session')} ║`);
|
|
311
|
+
console.log(chalk.bold(' ╚════════════════════════════════════════════════════╝'));
|
|
312
|
+
console.log('');
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
renderDashboard();
|
|
316
|
+
await typeText(chalk.dim(` Listening for incoming connections on port ${serviceConfig.port}...`), 30);
|
|
317
|
+
console.log('');
|
|
221
318
|
|
|
222
319
|
const waitForAction = async () => {
|
|
223
320
|
try {
|
|
321
|
+
const choices = [
|
|
322
|
+
{ name: '📡 See detailed client telemetry', value: 'show' },
|
|
323
|
+
{ name: '📺 Mirror Client Terminal (requires tmux)', value: 'mirror' },
|
|
324
|
+
{ name: '🔄 Re-register with broker', value: 'reregister' }
|
|
325
|
+
];
|
|
326
|
+
|
|
327
|
+
if (!chatServerInstance) {
|
|
328
|
+
choices.push({ name: '💬 Start Real-time Chat Room', value: 'chat' });
|
|
329
|
+
} else {
|
|
330
|
+
choices.push({ name: '💬 Re-open Chat Room in Browser', value: 'reopen_chat' });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
choices.push(
|
|
334
|
+
{ name: '🚫 Terminate all connections', value: 'terminate' },
|
|
335
|
+
{ name: '❌ Shut down session', value: 'exit' }
|
|
336
|
+
);
|
|
337
|
+
|
|
224
338
|
const { action } = await inquirer.prompt([
|
|
225
339
|
{
|
|
226
340
|
type: 'list',
|
|
227
341
|
name: 'action',
|
|
228
342
|
message: 'Host Controls:',
|
|
229
|
-
choices
|
|
230
|
-
{ name: '📡 Show connected clients', value: 'show' },
|
|
231
|
-
{ name: '🔄 Re-register with broker', value: 'reregister' },
|
|
232
|
-
{ name: '🚫 Terminate all connections', value: 'terminate' },
|
|
233
|
-
{ name: '❌ Shut down session', value: 'exit' },
|
|
234
|
-
],
|
|
343
|
+
choices,
|
|
235
344
|
},
|
|
236
345
|
]);
|
|
237
346
|
|
|
238
347
|
switch (action) {
|
|
348
|
+
case 'chat': {
|
|
349
|
+
console.log(chalk.dim('\n Starting chat server...'));
|
|
350
|
+
chatServerInstance = await startChatServer(async () => {
|
|
351
|
+
if (chatTunnelProcess) {
|
|
352
|
+
chatTunnelProcess.kill();
|
|
353
|
+
chatTunnelProcess = null;
|
|
354
|
+
chatServerInstance = null;
|
|
355
|
+
delete serviceConfig.chatUrl;
|
|
356
|
+
await registerWithBroker(uid, tunnelUrl, password, serviceConfig);
|
|
357
|
+
renderDashboard();
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
console.log(chalk.dim(' Provisioning Cloudflare tunnel for chat...'));
|
|
362
|
+
chatTunnelProcess = await spawnTunnelSupervised(`http://localhost:${chatServerInstance.port}`, async (newUrl) => {
|
|
363
|
+
serviceConfig.chatUrl = newUrl;
|
|
364
|
+
await registerWithBroker(uid, tunnelUrl, password, serviceConfig);
|
|
365
|
+
renderDashboard();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
while (!serviceConfig.chatUrl) {
|
|
369
|
+
await new Promise(r => setTimeout(r, 100));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
console.log(chalk.green(' ✅ Chat Room Live! Clients can now join.'));
|
|
373
|
+
await openLocalChatUI(chatServerInstance.port, password);
|
|
374
|
+
return waitForAction();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
case 'reopen_chat': {
|
|
378
|
+
if (chatServerInstance) await openLocalChatUI(chatServerInstance.port, password);
|
|
379
|
+
return waitForAction();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
case 'mirror': {
|
|
383
|
+
console.log('');
|
|
384
|
+
console.log(chalk.bold.cyan(' 📺 Terminal Mirroring'));
|
|
385
|
+
console.log(chalk.dim(' ──────────────────────────────────────'));
|
|
386
|
+
console.log(chalk.dim(' Attaching to client session. Press Ctrl+b then d to detach gracefully!'));
|
|
387
|
+
console.log(chalk.dim(' If no client has connected yet or tmux is missing, this will fail.'));
|
|
388
|
+
console.log('');
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
await execaCommand('tmux attach -t SecureLink_Session -r', { stdio: 'inherit' });
|
|
392
|
+
} catch {
|
|
393
|
+
console.log(chalk.yellow(' ⚠️ Could not attach. Ensure a client is connected and tmux is installed.'));
|
|
394
|
+
}
|
|
395
|
+
return waitForAction();
|
|
396
|
+
}
|
|
397
|
+
|
|
239
398
|
case 'show': {
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
399
|
+
const spinner = createSpinner('Fetching secure client telemetry...', networkSpinner).start();
|
|
400
|
+
try {
|
|
401
|
+
const res = await fetch(`${BROKER_URL}/clients/${uid}`);
|
|
402
|
+
if (!res.ok) throw new Error('Failed to fetch from broker');
|
|
403
|
+
const data = await res.json();
|
|
404
|
+
|
|
405
|
+
if (!data.clients || data.clients.length === 0) {
|
|
406
|
+
spinner.warn('No clients have successfully connected and sent telemetry yet.');
|
|
407
|
+
} else {
|
|
408
|
+
spinner.succeed(`Found ${data.clients.length} recent connection(s):`);
|
|
409
|
+
|
|
410
|
+
data.clients.forEach((clientBlob, i) => {
|
|
411
|
+
try {
|
|
412
|
+
// Decrypt using the unique salt the client generated for this payload
|
|
413
|
+
const decrypted = decrypt(clientBlob.iv, clientBlob.ciphertext, password, clientBlob.salt);
|
|
414
|
+
const t = JSON.parse(decrypted);
|
|
415
|
+
|
|
416
|
+
console.log(chalk.bold.blue(`\n Client #${i+1} (${t.username})`));
|
|
417
|
+
console.log(` IP: ${chalk.white(t.ip)}`);
|
|
418
|
+
console.log(` OS: ${chalk.dim(t.os)}`);
|
|
419
|
+
console.log(` CPU: ${chalk.dim(t.cpu)}`);
|
|
420
|
+
console.log(` RAM: ${chalk.dim(t.ram)}`);
|
|
421
|
+
console.log(` Time: ${chalk.dim(t.time)}`);
|
|
422
|
+
} catch (e) {
|
|
423
|
+
console.log(chalk.yellow(`\n Client #${i+1}: Payload decryption failed (wrong password or corrupted).`));
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
} catch (e) {
|
|
428
|
+
spinner.fail('Could not reach broker.');
|
|
246
429
|
}
|
|
430
|
+
console.log('');
|
|
247
431
|
return waitForAction();
|
|
248
432
|
}
|
|
249
433
|
|
|
250
434
|
case 'reregister':
|
|
251
|
-
await registerWithBroker(uid, tunnelUrl);
|
|
435
|
+
await registerWithBroker(uid, tunnelUrl, password, serviceConfig);
|
|
252
436
|
return waitForAction();
|
|
253
437
|
|
|
254
438
|
case 'terminate': {
|
|
@@ -268,6 +452,9 @@ async function hostDashboard(uid, tunnelUrl) {
|
|
|
268
452
|
|
|
269
453
|
case 'exit':
|
|
270
454
|
clearInterval(monitorInterval);
|
|
455
|
+
if (tunnelProcess) tunnelProcess.kill();
|
|
456
|
+
if (chatTunnelProcess) chatTunnelProcess.kill();
|
|
457
|
+
if (global.privateBrokerInstance) global.privateBrokerInstance.kill();
|
|
271
458
|
return;
|
|
272
459
|
}
|
|
273
460
|
} catch (err) {
|
|
@@ -292,25 +479,135 @@ export async function startHostMode() {
|
|
|
292
479
|
console.log(` ${chalk.green('✓')} Session UID: ${chalk.bold.white(uid)}`);
|
|
293
480
|
console.log('');
|
|
294
481
|
|
|
295
|
-
await
|
|
482
|
+
const { pwdInput } = await inquirer.prompt([
|
|
483
|
+
{
|
|
484
|
+
type: 'input',
|
|
485
|
+
name: 'pwdInput',
|
|
486
|
+
message: 'Enter a session password to encrypt the tunnel (leave blank to auto-generate):',
|
|
487
|
+
},
|
|
488
|
+
]);
|
|
489
|
+
const password = pwdInput.trim() || generateUID();
|
|
490
|
+
console.log(` ${chalk.green('✓')} Password: ${chalk.bold.white(password)}`);
|
|
491
|
+
console.log('');
|
|
296
492
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
493
|
+
// ─── Broker Selection ───
|
|
494
|
+
if (!process.env.BROKER_URL) {
|
|
495
|
+
const { brokerChoice } = await inquirer.prompt([
|
|
496
|
+
{
|
|
497
|
+
type: 'list',
|
|
498
|
+
name: 'brokerChoice',
|
|
499
|
+
message: 'Which Broker Server would you like to use?',
|
|
500
|
+
choices: [
|
|
501
|
+
{ name: '🌍 Global Public Broker (Render)', value: 'global' },
|
|
502
|
+
{ name: '🛠️ Create a Private Broker (Local + Cloudflare)', value: 'create_private' }
|
|
503
|
+
]
|
|
504
|
+
}
|
|
505
|
+
]);
|
|
506
|
+
|
|
507
|
+
if (brokerChoice === 'create_private') {
|
|
508
|
+
global.privateBrokerInstance = await spawnPrivateBroker();
|
|
509
|
+
BROKER_URL = global.privateBrokerInstance.url;
|
|
510
|
+
}
|
|
305
511
|
}
|
|
306
512
|
|
|
307
|
-
|
|
308
|
-
if (!
|
|
309
|
-
|
|
310
|
-
|
|
513
|
+
// Only ping if we haven't just created a private broker
|
|
514
|
+
if (!global.privateBrokerInstance) {
|
|
515
|
+
const spinner = createSpinner(`Checking broker status at ${BROKER_URL}...`, networkSpinner).start();
|
|
516
|
+
const brokerOnline = await pingBroker(BROKER_URL);
|
|
517
|
+
|
|
518
|
+
if (brokerOnline) {
|
|
519
|
+
spinner.succeed(`Broker is online ${chalk.dim(`(${BROKER_URL})`)}`);
|
|
520
|
+
} else {
|
|
521
|
+
spinner.warn(`Broker is unreachable ${chalk.dim(`(${BROKER_URL})`)}`);
|
|
522
|
+
const { startPrivate } = await inquirer.prompt([
|
|
523
|
+
{
|
|
524
|
+
type: 'confirm',
|
|
525
|
+
name: 'startPrivate',
|
|
526
|
+
message: 'The broker is offline. Do you want to auto-spawn a Private Broker on this machine?',
|
|
527
|
+
default: true
|
|
528
|
+
}
|
|
529
|
+
]);
|
|
530
|
+
if (startPrivate) {
|
|
531
|
+
global.privateBrokerInstance = await spawnPrivateBroker();
|
|
532
|
+
BROKER_URL = global.privateBrokerInstance.url;
|
|
533
|
+
} else {
|
|
534
|
+
console.log(chalk.red('\n ❌ FATAL: Cannot continue without a broker.'));
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
console.log('');
|
|
541
|
+
const { serviceType } = await inquirer.prompt([
|
|
542
|
+
{
|
|
543
|
+
type: 'list',
|
|
544
|
+
name: 'serviceType',
|
|
545
|
+
message: 'What service do you want to expose?',
|
|
546
|
+
choices: [
|
|
547
|
+
{ name: '🖥️ SSH (Port 22)', value: 'ssh' },
|
|
548
|
+
{ name: '🌐 Web/HTTP (Custom Port)', value: 'http' },
|
|
549
|
+
{ name: '🔌 Custom TCP Port (e.g. Database, RDP, VNC)', value: 'tcp' }
|
|
550
|
+
]
|
|
551
|
+
}
|
|
552
|
+
]);
|
|
553
|
+
|
|
554
|
+
let targetPort = 22;
|
|
555
|
+
let protocol = 'ssh';
|
|
556
|
+
|
|
557
|
+
if (serviceType === 'http') {
|
|
558
|
+
const ans = await inquirer.prompt([{ name: 'port', message: 'Enter local HTTP port (e.g. 3000):', default: '3000' }]);
|
|
559
|
+
targetPort = ans.port;
|
|
560
|
+
protocol = 'http';
|
|
561
|
+
} else if (serviceType === 'tcp') {
|
|
562
|
+
const ans = await inquirer.prompt([{ name: 'port', message: 'Enter local TCP port (e.g. 3389 for RDP, 5432 for Postgres):' }]);
|
|
563
|
+
targetPort = ans.port;
|
|
564
|
+
protocol = 'tcp';
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const serviceConfig = { type: serviceType, port: targetPort, protocol };
|
|
568
|
+
const targetUrl = `${protocol}://localhost:${targetPort}`;
|
|
569
|
+
|
|
570
|
+
if (serviceType === 'ssh') {
|
|
571
|
+
await ensureSSHRunning();
|
|
572
|
+
console.log(chalk.dim(' 🔑 Generating ephemeral SSH key for passwordless entry...'));
|
|
573
|
+
try {
|
|
574
|
+
const ephemeralKey = await generateEphemeralKey();
|
|
575
|
+
const authKeysPath = await injectPublicKey(ephemeralKey.pubKey);
|
|
576
|
+
|
|
577
|
+
serviceConfig.privateKey = ephemeralKey.privKey;
|
|
578
|
+
|
|
579
|
+
addCleanupHook(async () => {
|
|
580
|
+
console.log(chalk.dim(' Removing ephemeral public key...'));
|
|
581
|
+
await removePublicKey(authKeysPath, ephemeralKey.pubKey);
|
|
582
|
+
try { await fs.promises.unlink(ephemeralKey.keyPath); } catch {}
|
|
583
|
+
try { await fs.promises.unlink(`${ephemeralKey.keyPath}.pub`); } catch {}
|
|
584
|
+
});
|
|
585
|
+
console.log(chalk.green(' ✓ Ephemeral key injected. Client will connect without system password!'));
|
|
586
|
+
} catch (err) {
|
|
587
|
+
console.log(chalk.yellow(` ⚠️ Could not generate ephemeral key: ${err.message}`));
|
|
588
|
+
console.log(chalk.dim(' Client will need to use standard OS password.'));
|
|
589
|
+
}
|
|
590
|
+
} else {
|
|
591
|
+
console.log(chalk.dim(` ℹ️ Ensure your ${protocol.toUpperCase()} service is running on port ${targetPort}.`));
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
let tunnelUrl = null;
|
|
595
|
+
const tunnelProcess = await spawnTunnelSupervised(targetUrl, async (newUrl) => {
|
|
596
|
+
tunnelUrl = newUrl;
|
|
597
|
+
// Register or re-register with broker when tunnel is spawned/respawned
|
|
598
|
+
const registered = await registerWithBroker(uid, tunnelUrl, password, serviceConfig);
|
|
599
|
+
if (!registered) {
|
|
600
|
+
console.error(chalk.red(`\n ❌ FATAL: Could not register with broker at ${BROKER_URL}`));
|
|
601
|
+
process.exit(1);
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
// Wait for the first URL to be generated before showing the dashboard
|
|
606
|
+
while (!tunnelUrl) {
|
|
607
|
+
await new Promise(r => setTimeout(r, 100));
|
|
311
608
|
}
|
|
312
609
|
|
|
313
610
|
setRevokeOnExit(uid, BROKER_URL);
|
|
314
611
|
|
|
315
|
-
await hostDashboard(uid,
|
|
612
|
+
await hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProcess);
|
|
316
613
|
}
|