@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/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 BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
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(`SSH check failed: ${err.message}`);
80
- console.log(chalk.dim(' Continue anyway? The tunnel will still start, but SSH connections may fail.'));
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
- * Spawn cloudflared tunnel and extract the generated URL.
90
+ * Supervise cloudflared tunnel, restarting it if it crashes.
86
91
  */
87
- async function spawnTunnel() {
88
- const spinner = createSpinner('Starting Cloudflare tunnel...', tunnelSpinner).start();
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
- return new Promise((resolve, reject) => {
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
- trackPID(child.pid);
97
- let tunnelUrl = null;
98
- let resolved = false;
99
-
100
- child.all.on('data', (chunk) => {
101
- const text = chunk.toString();
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
- child.on('exit', (code) => {
112
- untrackPID(child.pid);
113
- if (!resolved) {
114
- spinner.fail('Cloudflare tunnel exited before generating URL');
115
- reject(new Error(`cloudflared exited with code ${code}`));
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
- child.on('error', (err) => {
120
- untrackPID(child.pid);
121
- if (!resolved) {
122
- spinner.fail(`Tunnel error: ${err.message}`);
123
- reject(err);
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
- setTimeout(() => {
128
- if (!resolved) {
129
- spinner.fail('Timeout: No tunnel URL received after 30 seconds');
130
- reject(new Error('Tunnel timeout'));
131
- }
132
- }, 30000);
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 URL and register with the Central Broker.
244
+ * Encrypt tunnel details and register with the Central Broker.
138
245
  */
139
- async function registerWithBroker(uid, tunnelUrl) {
140
- const spinner = createSpinner('Encrypting tunnel URL...', cryptoSpinner).start();
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 tunnel URL LOCALLY before sending
250
+ // Encrypt the JSON payload LOCALLY before sending
144
251
  await new Promise(r => setTimeout(r, 600)); // animation effect
145
- const encrypted = encrypt(tunnelUrl);
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
- console.log('');
199
- console.log(chalk.bold(' ╔════════════════════════════════════════════════════╗'));
200
- console.log(chalk.bold(' ║ 🛡️ SecureLink — HOST MODE ACTIVE ║'));
201
- console.log(chalk.bold(' ╠════════════════════════════════════════════════════╣'));
202
- console.log(` ║ ${chalk.cyan('UID:')} ${chalk.bold.white(uid)} ║`);
203
- console.log(` ║ ${chalk.cyan('Tunnel:')} ${chalk.dim(tunnelUrl.substring(0, 40))} ║`);
204
- console.log(` ║ ${chalk.cyan('Broker:')} ${chalk.dim(BROKER_URL.padEnd(25))} ║`);
205
- console.log(` ║ ${chalk.cyan('Crypto:')} ${chalk.green('AES-256-CBC E2E')} ║`);
206
- console.log(chalk.bold(' ╠════════════════════════════════════════════════════╣'));
207
- console.log(` ║ ${chalk.yellow('Share this UID with the person who needs access')} ║`);
208
- console.log(` ║ ${chalk.dim('Press Ctrl+C to terminate the session')} ║`);
209
- console.log(chalk.bold(' ╚════════════════════════════════════════════════════╝'));
210
- console.log('');
211
-
212
- await typeText(chalk.dim(' Listening for incoming connections...'), 30);
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
- }, 15000);
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 ips = await getConnectedIPs();
241
- if (ips.length === 0) {
242
- console.log(chalk.dim(' No active connections.'));
243
- } else {
244
- console.log(chalk.cyan(` 📡 ${ips.length} connected client(s):`));
245
- ips.forEach(ip => console.log(` ${chalk.white('→')} ${ip}`));
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 ensureSSHRunning();
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
- let tunnel;
298
- try {
299
- tunnel = await spawnTunnel();
300
- } catch (err) {
301
- console.error(chalk.red(`\n ❌ FATAL: Failed to start tunnel`));
302
- console.error(chalk.red(` Error: ${err.message}`));
303
- console.log(chalk.dim(' Make sure cloudflared is installed and in your PATH.'));
304
- process.exit(1);
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
- const registered = await registerWithBroker(uid, tunnel.url);
308
- if (!registered) {
309
- console.error(chalk.red(`\n ❌ FATAL: Could not register with broker at ${BROKER_URL}`));
310
- process.exit(1);
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, tunnel.url);
612
+ await hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProcess);
316
613
  }