@miraj181/ipingyou 2.1.9 → 2.1.18

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
@@ -18,10 +18,13 @@ import chalk from 'chalk';
18
18
  import inquirer from 'inquirer';
19
19
  import path from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
+ import { createRequire } from 'node:module';
21
22
  import fs from 'node:fs';
22
23
  import os from 'node:os';
24
+ import crypto from 'node:crypto';
23
25
  import { generateUID } from '../lib/uid.js';
24
- import { decrypt } from '../lib/crypto.js';
26
+ import { openUrl } from '../lib/open-url.js';
27
+ import { decryptAsync } from '../lib/crypto.js';
25
28
  import { cleanupAll, killProcessTree, trackPID, untrackPID, setRevokeOnExit, addCleanupHook } from '../lib/cleanup.js';
26
29
  import { detectOS } from '../lib/platform.js';
27
30
  import { createSpinner, networkSpinner, typeText } from '../lib/animations.js';
@@ -35,6 +38,16 @@ import { TMUX_SESSION_NAME, TMUX_SESSION_PREFIX, tmuxSocketArgs } from '../lib/t
35
38
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
39
  let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
37
40
 
41
+ function escapeDashboardHtml(value) {
42
+ return String(value ?? '').replace(/[&<>"']/g, character => ({
43
+ '&': '&amp;',
44
+ '<': '&lt;',
45
+ '>': '&gt;',
46
+ '"': '&quot;',
47
+ "'": '&#39;',
48
+ })[character]);
49
+ }
50
+
38
51
  async function waitForValue(getValue, timeoutMs, label) {
39
52
  const startedAt = Date.now();
40
53
  while (!getValue()) {
@@ -208,15 +221,26 @@ async function injectPublicKey(pubKey) {
208
221
  }
209
222
 
210
223
  const authKeysPath = path.join(sshDir, 'authorized_keys');
211
- await fs.promises.appendFile(authKeysPath, `\n${pubKey}\n`);
212
- return authKeysPath;
224
+ const existing = await fs.promises.lstat(authKeysPath).catch(() => null);
225
+ if (existing?.isSymbolicLink()) {
226
+ throw new Error('Refusing to modify a symlinked authorized_keys file');
227
+ }
228
+ const authorizedKey = `no-agent-forwarding,no-X11-forwarding ${pubKey}`;
229
+ await fs.promises.appendFile(authKeysPath, `\n${authorizedKey}\n`, { mode: 0o600 });
230
+ await fs.promises.chmod(authKeysPath, 0o600);
231
+ return { authKeysPath, authorizedKey };
213
232
  }
214
233
 
215
- async function removePublicKey(authKeysPath, pubKey) {
234
+ async function removePublicKey(authKeysPath, authorizedKey) {
216
235
  if (fs.existsSync(authKeysPath)) {
236
+ const stat = await fs.promises.lstat(authKeysPath);
237
+ if (stat.isSymbolicLink()) {
238
+ throw new Error('Refusing to modify a symlinked authorized_keys file');
239
+ }
217
240
  let keys = await fs.promises.readFile(authKeysPath, 'utf8');
218
- keys = keys.replace(`\n${pubKey}\n`, '');
241
+ keys = keys.replace(`\n${authorizedKey}\n`, '');
219
242
  await fs.promises.writeFile(authKeysPath, keys);
243
+ await fs.promises.chmod(authKeysPath, 0o600);
220
244
  }
221
245
  }
222
246
 
@@ -274,9 +298,51 @@ async function promptOneTimeSharePath() {
274
298
 
275
299
  async function startLocalHostDashboard(uid, password, serviceConfig, sessionState) {
276
300
  const { default: express } = await import('express');
277
- const { default: open } = await import('open');
278
301
  const app = express();
279
302
  const startedAt = new Date().toISOString();
303
+ const decryptedClientCache = new Map();
304
+ const MAX_DECRYPTED_CLIENT_CACHE = 100;
305
+ const activeEventStreams = new Set();
306
+ const MAX_EVENT_STREAMS = 5;
307
+ const dashboardUid = escapeDashboardHtml(uid);
308
+ const dashboardService = escapeDashboardHtml(String(serviceConfig.type || '').toUpperCase());
309
+ const dashboardPort = escapeDashboardHtml(serviceConfig.port);
310
+ const dashboardDropPath = escapeDashboardHtml(serviceConfig.sharedDropPath || 'none');
311
+ const dashboardSharePath = escapeDashboardHtml(serviceConfig.oneTimeSharePath || 'none');
312
+
313
+ async function fetchDecryptedClients() {
314
+ const brokerRes = await fetch(`${BROKER_URL}/clients/${uid}`, {
315
+ headers: sessionState.hostToken ? { 'x-host-token': sessionState.hostToken } : {}
316
+ });
317
+ if (!brokerRes.ok) {
318
+ const data = await brokerRes.json().catch(() => ({}));
319
+ throw new Error(data.error || 'Failed to fetch clients');
320
+ }
321
+ const data = await brokerRes.json();
322
+ const activeCacheKeys = new Set();
323
+ const decryptedClients = await Promise.all((data.clients || []).map(async (clientBlob) => {
324
+ const cacheKey = `${clientBlob.iv}:${clientBlob.salt}:${clientBlob.ciphertext}`;
325
+ activeCacheKeys.add(cacheKey);
326
+ const cached = decryptedClientCache.get(cacheKey);
327
+ if (cached) return { ...cached, seenAt: clientBlob.seenAt || null };
328
+ try {
329
+ const decrypted = await decryptAsync(clientBlob.iv, clientBlob.ciphertext, password, clientBlob.salt);
330
+ const t = JSON.parse(decrypted);
331
+ decryptedClientCache.set(cacheKey, t);
332
+ return { ...t, seenAt: clientBlob.seenAt || null };
333
+ } catch {
334
+ const failed = { error: 'decrypt_failed' };
335
+ decryptedClientCache.set(cacheKey, failed);
336
+ return { ...failed, seenAt: clientBlob.seenAt || null };
337
+ }
338
+ }));
339
+ for (const key of decryptedClientCache.keys()) {
340
+ if (!activeCacheKeys.has(key) || decryptedClientCache.size > MAX_DECRYPTED_CLIENT_CACHE) {
341
+ decryptedClientCache.delete(key);
342
+ }
343
+ }
344
+ return { clients: decryptedClients };
345
+ }
280
346
 
281
347
  app.get('/api/status', (_req, res) => {
282
348
  res.json({
@@ -293,47 +359,91 @@ async function startLocalHostDashboard(uid, password, serviceConfig, sessionStat
293
359
 
294
360
  app.use(express.json());
295
361
 
362
+ app.get('/api/approvals', async (_req, res) => {
363
+ try {
364
+ const data = await fetchApprovalRequests(BROKER_URL, uid, sessionState.hostToken);
365
+ res.json(data);
366
+ } catch (err) {
367
+ res.status(500).json({ error: err.message });
368
+ }
369
+ });
370
+
296
371
  // Server-Sent Events for live telemetry & approvals
297
372
  app.get('/api/events', async (req, res) => {
373
+ if (activeEventStreams.size >= MAX_EVENT_STREAMS) {
374
+ return res.status(503).json({ error: 'Too many dashboard event streams' });
375
+ }
376
+ activeEventStreams.add(res);
298
377
  res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
299
378
  res.flushHeaders?.();
300
379
 
301
380
  let closed = false;
302
- const push = async () => {
381
+ let timer = null;
382
+ let intervalMs = 5000;
383
+ let unchangedCycles = 0;
384
+ let lastApprovals = '';
385
+ let lastClients = '';
386
+
387
+ const writeEvent = (event, payload) => {
388
+ if (closed) return;
389
+ res.write(`event: ${event}\n`);
390
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
391
+ };
392
+
393
+ const scheduleNext = () => {
394
+ if (closed) return;
395
+ timer = setTimeout(pushLoop, intervalMs);
396
+ };
397
+
398
+ const pushLoop = async () => {
303
399
  if (closed) return;
304
400
  try {
305
- const data = await fetchApprovalRequests(BROKER_URL, uid, sessionState.hostToken).catch(() => ({ approvals: [] }));
306
- res.write(`event: approvals\n`);
307
- res.write(`data: ${JSON.stringify(data)}\n\n`);
308
- } catch { }
401
+ const [approvalData, clientData] = await Promise.all([
402
+ fetchApprovalRequests(BROKER_URL, uid, sessionState.hostToken).catch(() => ({ approvals: [] })),
403
+ fetchDecryptedClients().catch(() => ({ clients: [] })),
404
+ ]);
405
+
406
+ const approvalsPayload = { approvals: approvalData.approvals || [] };
407
+ const clientsPayload = { clients: clientData.clients || [] };
408
+ const approvalsHash = JSON.stringify(approvalsPayload.approvals);
409
+ const clientsHash = JSON.stringify(clientsPayload.clients);
410
+ const changed = approvalsHash !== lastApprovals || clientsHash !== lastClients;
411
+
412
+ if (approvalsHash !== lastApprovals) {
413
+ writeEvent('approvals', approvalsPayload);
414
+ lastApprovals = approvalsHash;
415
+ }
416
+ if (clientsHash !== lastClients) {
417
+ writeEvent('clients', clientsPayload);
418
+ lastClients = clientsHash;
419
+ }
420
+
421
+ if (changed) {
422
+ unchangedCycles = 0;
423
+ intervalMs = 5000;
424
+ } else {
425
+ unchangedCycles += 1;
426
+ intervalMs = Math.min(20000, 5000 * (2 ** Math.min(2, unchangedCycles)));
427
+ }
428
+ } finally {
429
+ scheduleNext();
430
+ }
309
431
  };
310
432
 
311
- const iv = setInterval(push, 5000);
312
- req.on('close', () => { clearInterval(iv); closed = true; });
313
- // send initial payload
314
- await push();
433
+ req.on('close', () => {
434
+ closed = true;
435
+ activeEventStreams.delete(res);
436
+ if (timer) clearTimeout(timer);
437
+ });
438
+
439
+ writeEvent('ready', { ok: true });
440
+ await pushLoop();
315
441
  });
316
442
 
317
443
  app.get('/api/clients', async (_req, res) => {
318
444
  try {
319
- const brokerRes = await fetch(`${BROKER_URL}/clients/${uid}`, {
320
- headers: sessionState.hostToken ? { 'x-host-token': sessionState.hostToken } : {}
321
- });
322
- if (!brokerRes.ok) {
323
- const data = await brokerRes.json().catch(() => ({}));
324
- return res.status(brokerRes.status).json({ error: data.error || 'Failed to fetch clients' });
325
- }
326
- const data = await brokerRes.json();
327
- const clients = (data.clients || []).map((clientBlob) => {
328
- try {
329
- const decrypted = decrypt(clientBlob.iv, clientBlob.ciphertext, password, clientBlob.salt);
330
- const t = JSON.parse(decrypted);
331
- return { ...t, seenAt: clientBlob.seenAt || null };
332
- } catch {
333
- return { error: 'decrypt_failed', seenAt: clientBlob.seenAt || null };
334
- }
335
- });
336
- res.json({ clients });
445
+ const clients = await fetchDecryptedClients();
446
+ res.json(clients);
337
447
  } catch (err) {
338
448
  res.status(500).json({ error: err.message });
339
449
  }
@@ -381,6 +491,17 @@ async function startLocalHostDashboard(uid, password, serviceConfig, sessionStat
381
491
  });
382
492
 
383
493
  app.get('/', (_req, res) => {
494
+ const scriptNonce = crypto.randomBytes(18).toString('base64');
495
+ res.set('Content-Security-Policy', [
496
+ "default-src 'self'",
497
+ `script-src 'nonce-${scriptNonce}'`,
498
+ "style-src 'self' 'unsafe-inline'",
499
+ "connect-src 'self'",
500
+ "img-src 'none'",
501
+ "object-src 'none'",
502
+ "base-uri 'none'",
503
+ "frame-ancestors 'none'",
504
+ ].join('; '));
384
505
  res.type('html').send(`<!doctype html>
385
506
  <html lang="en"><head><meta charset="utf-8"><title>iPingYou Host Dashboard</title>
386
507
  <style>
@@ -421,22 +542,22 @@ code{background:var(--bg);padding:2px 8px;border-radius:4px;font-size:0.85rem}
421
542
  </style></head>
422
543
  <body>
423
544
  <div class="header">
424
- <div><h1>🛡️ iPingYou Host Dashboard</h1><span style="color:var(--dim);font-size:0.85rem">UID: <code>${uid}</code></span></div>
545
+ <div><h1>🛡️ iPingYou Host Dashboard</h1><span style="color:var(--dim);font-size:0.85rem">UID: <code>${dashboardUid}</code></span></div>
425
546
  <div style="display:flex;gap:1rem;align-items:center">
426
547
  <span class="badge">● LIVE</span>
427
- <button class="btn btn-revoke" onclick="revokeSession()">🚫 Revoke Session</button>
548
+ <button id="revoke-session" class="btn btn-revoke" type="button">🚫 Revoke Session</button>
428
549
  </div>
429
550
  </div>
430
551
 
431
552
  <div class="grid">
432
553
  <div class="card">
433
554
  <h2>📊 Session Info</h2>
434
- <div class="info-row"><span class="info-label">UID</span><span class="info-value"><code>${uid}</code></span></div>
555
+ <div class="info-row"><span class="info-label">UID</span><span class="info-value"><code>${dashboardUid}</code></span></div>
435
556
  <div class="info-row"><span class="info-label">Password</span><span class="info-value"><code>[Hidden — see terminal]</code></span></div>
436
- <div class="info-row"><span class="info-label">Service</span><span class="info-value">${serviceConfig.type.toUpperCase()} on port ${serviceConfig.port}</span></div>
557
+ <div class="info-row"><span class="info-label">Service</span><span class="info-value">${dashboardService} on port ${dashboardPort}</span></div>
437
558
  <div class="info-row"><span class="info-label">Approval Gate</span><span class="info-value">${serviceConfig.approvalRequired ? '<span style="color:var(--green)">✓ Enabled</span>' : '<span style="color:var(--dim)">Disabled</span>'}</span></div>
438
- <div class="info-row"><span class="info-label">Drop Folder</span><span class="info-value"><code>${serviceConfig.sharedDropPath || 'none'}</code></span></div>
439
- <div class="info-row"><span class="info-label">One-Time Share</span><span class="info-value"><code>${serviceConfig.oneTimeSharePath || 'none'}</code></span></div>
559
+ <div class="info-row"><span class="info-label">Drop Folder</span><span class="info-value"><code>${dashboardDropPath}</code></span></div>
560
+ <div class="info-row"><span class="info-label">One-Time Share</span><span class="info-value"><code>${dashboardSharePath}</code></span></div>
440
561
  <div class="info-row"><span class="info-label">Chat</span><span class="info-value">${serviceConfig.chatUrl ? '<span style="color:var(--green)">Active</span>' : '<span style="color:var(--dim)">Not started</span>'}</span></div>
441
562
  <div class="info-row"><span class="info-label">Uptime</span><span class="info-value" id="uptime">—</span></div>
442
563
  </div>
@@ -454,7 +575,7 @@ code{background:var(--bg);padding:2px 8px;border-radius:4px;font-size:0.85rem}
454
575
 
455
576
  <div id="toast"></div>
456
577
 
457
- <script>
578
+ <script nonce="${scriptNonce}">
458
579
  const startedAt = new Date("${startedAt}");
459
580
 
460
581
  function showToast(msg) {
@@ -464,14 +585,11 @@ function showToast(msg) {
464
585
  setTimeout(() => t.classList.remove('show'), 2500);
465
586
  }
466
587
 
467
- function escapeHtml(value) {
468
- return String(value || '').replace(/[&<>"']/g, (m) => ({
469
- '&': '&amp;',
470
- '<': '&lt;',
471
- '>': '&gt;',
472
- '"': '&quot;',
473
- "'": '&#39;',
474
- })[m]);
588
+ function appendEmptyState(container, text) {
589
+ const message = document.createElement('p');
590
+ message.className = 'empty';
591
+ message.textContent = text;
592
+ container.replaceChildren(message);
475
593
  }
476
594
 
477
595
  function updateUptime() {
@@ -486,12 +604,44 @@ updateUptime();
486
604
 
487
605
  // SSE for live updates
488
606
  const es = new EventSource('/api/events');
607
+ let fallbackTimer = null;
608
+ let fallbackDelay = 5000;
609
+
610
+ function scheduleFallbackPoll() {
611
+ if (fallbackTimer) return;
612
+ fallbackTimer = setTimeout(async function pollFallback() {
613
+ fallbackTimer = null;
614
+ try {
615
+ const [approvalsRes, clientsRes] = await Promise.all([
616
+ fetch('/api/approvals'),
617
+ fetch('/api/clients')
618
+ ]);
619
+ if (approvalsRes.ok) renderApprovals((await approvalsRes.json()).approvals || []);
620
+ if (clientsRes.ok) renderClients((await clientsRes.json()).clients || []);
621
+ } catch {}
622
+ fallbackDelay = Math.min(20000, fallbackDelay * 2);
623
+ scheduleFallbackPoll();
624
+ }, fallbackDelay);
625
+ }
626
+
627
+ es.addEventListener('open', () => {
628
+ fallbackDelay = 5000;
629
+ if (fallbackTimer) clearTimeout(fallbackTimer);
630
+ fallbackTimer = null;
631
+ });
632
+ es.addEventListener('error', scheduleFallbackPoll);
489
633
  es.addEventListener('approvals', (e) => {
490
634
  try {
491
635
  const data = JSON.parse(e.data);
492
636
  renderApprovals(data.approvals || []);
493
637
  } catch {}
494
638
  });
639
+ es.addEventListener('clients', (e) => {
640
+ try {
641
+ const data = JSON.parse(e.data);
642
+ renderClients(data.clients || []);
643
+ } catch {}
644
+ });
495
645
 
496
646
  function renderApprovals(approvals) {
497
647
  const container = document.getElementById('approvals');
@@ -500,39 +650,63 @@ function renderApprovals(approvals) {
500
650
  document.getElementById('approval-count').textContent = pending.length > 0 ? '(' + pending.length + ' pending)' : '';
501
651
 
502
652
  if (approvals.length === 0) {
503
- container.innerHTML = '<p class="empty">No approval requests yet</p>';
653
+ appendEmptyState(container, 'No approval requests yet');
504
654
  return;
505
655
  }
506
656
 
507
- let html = '';
657
+ const fragment = document.createDocumentFragment();
508
658
  for (const req of pending) {
509
- html += '<div class="approval-item">'
510
- + '<div style="display:flex;justify-content:space-between;align-items:center">'
511
- + '<strong>Request ' + req.id + '</strong>'
512
- + '<span class="status-badge status-pending">PENDING</span></div>'
513
- + '<div class="meta">Submitted: ' + new Date(req.createdAt).toLocaleTimeString() + '</div>'
514
- + '<div class="actions">'
515
- + '<button class="btn btn-approve" data-id="' + req.id + '" data-decision="approved">✅ Approve</button>'
516
- + '<button class="btn btn-deny" data-id="' + req.id + '" data-decision="denied">❌ Deny</button>'
517
- + '</div></div>';
659
+ const item = document.createElement('div');
660
+ item.className = 'approval-item';
661
+ const heading = document.createElement('div');
662
+ heading.style.cssText = 'display:flex;justify-content:space-between;align-items:center';
663
+ const title = document.createElement('strong');
664
+ title.textContent = 'Request ' + String(req.id || '');
665
+ const badge = document.createElement('span');
666
+ badge.className = 'status-badge status-pending';
667
+ badge.textContent = 'PENDING';
668
+ heading.append(title, badge);
669
+ const meta = document.createElement('div');
670
+ meta.className = 'meta';
671
+ meta.textContent = 'Submitted: ' + new Date(req.createdAt).toLocaleTimeString();
672
+ const actions = document.createElement('div');
673
+ actions.className = 'actions';
674
+ for (const choice of [
675
+ { decision: 'approved', label: '✅ Approve', className: 'btn btn-approve' },
676
+ { decision: 'denied', label: '❌ Deny', className: 'btn btn-deny' }
677
+ ]) {
678
+ const button = document.createElement('button');
679
+ button.className = choice.className;
680
+ button.type = 'button';
681
+ button.textContent = choice.label;
682
+ button.addEventListener('click', function() {
683
+ decide(String(req.id || ''), choice.decision);
684
+ });
685
+ actions.appendChild(button);
686
+ }
687
+ item.append(heading, meta, actions);
688
+ fragment.appendChild(item);
518
689
  }
519
690
  for (const req of decided) {
520
- const cls = req.status === 'approved' ? 'status-approved' : 'status-denied';
521
- html += '<div class="approval-item" style="opacity:0.6">'
522
- + '<div style="display:flex;justify-content:space-between;align-items:center">'
523
- + '<strong>Request ' + req.id + '</strong>'
524
- + '<span class="status-badge ' + cls + '">' + req.status.toUpperCase() + '</span></div>'
525
- + '<div class="meta">Decided: ' + (req.decidedAt ? new Date(req.decidedAt).toLocaleTimeString() : 'N/A') + '</div>'
526
- + '</div>';
691
+ const status = req.status === 'approved' ? 'approved' : 'denied';
692
+ const item = document.createElement('div');
693
+ item.className = 'approval-item';
694
+ item.style.opacity = '0.6';
695
+ const heading = document.createElement('div');
696
+ heading.style.cssText = 'display:flex;justify-content:space-between;align-items:center';
697
+ const title = document.createElement('strong');
698
+ title.textContent = 'Request ' + String(req.id || '');
699
+ const badge = document.createElement('span');
700
+ badge.className = 'status-badge status-' + status;
701
+ badge.textContent = status.toUpperCase();
702
+ heading.append(title, badge);
703
+ const meta = document.createElement('div');
704
+ meta.className = 'meta';
705
+ meta.textContent = 'Decided: ' + (req.decidedAt ? new Date(req.decidedAt).toLocaleTimeString() : 'N/A');
706
+ item.append(heading, meta);
707
+ fragment.appendChild(item);
527
708
  }
528
- container.innerHTML = html;
529
-
530
- // Attach click handlers via event delegation (avoids inline quote escaping issues)
531
- container.querySelectorAll('[data-decision]').forEach(function(btn) {
532
- btn.addEventListener('click', function() {
533
- decide(btn.getAttribute('data-id'), btn.getAttribute('data-decision'));
534
- });
535
- });
709
+ container.replaceChildren(fragment);
536
710
  }
537
711
 
538
712
  async function decide(requestId, decision) {
@@ -555,48 +729,50 @@ async function revokeSession() {
555
729
  else showToast('Failed to revoke');
556
730
  } catch (err) { showToast('Error: ' + err.message); }
557
731
  }
732
+ document.getElementById('revoke-session').addEventListener('click', revokeSession);
558
733
 
559
- // Poll client telemetry (separate from SSE for simplicity)
560
- async function pollClients() {
561
- try {
562
- const res = await fetch('/api/clients');
563
- if (!res.ok) return;
564
- const data = await res.json();
565
- renderClients(data.clients || []);
566
- } catch {}
567
- }
568
-
569
734
  function renderClients(clients) {
570
735
  const container = document.getElementById('clients');
571
736
  if (!clients || clients.length === 0) {
572
- container.innerHTML = '<p class="empty">No clients connected yet</p>';
737
+ appendEmptyState(container, 'No clients connected yet');
573
738
  document.getElementById('client-count').textContent = '';
574
739
  return;
575
740
  }
576
741
 
577
742
  document.getElementById('client-count').textContent = '(' + clients.length + ' connected)';
578
- let html = '';
743
+ const fragment = document.createDocumentFragment();
579
744
  clients.forEach((c, idx) => {
745
+ const card = document.createElement('div');
746
+ card.className = 'client-card';
580
747
  if (c.error) {
581
- html += '<div class="client-card"><strong>Client #' + (idx + 1) + '</strong> — payload decryption failed</div>';
748
+ const title = document.createElement('strong');
749
+ title.textContent = 'Client #' + (idx + 1);
750
+ card.append(title, document.createTextNode(' — payload decryption failed'));
751
+ fragment.appendChild(card);
582
752
  return;
583
753
  }
584
754
  const when = c.time || (c.seenAt ? new Date(c.seenAt).toLocaleTimeString() : 'Unknown');
585
- html += '<div class="client-card">'
586
- + '<strong>' + escapeHtml(c.username || 'Unknown') + '</strong>'
587
- + ' — ' + escapeHtml(c.action || 'connected') + '<br>'
588
- + '<span class="meta">IP: ' + escapeHtml(c.ip || 'Unknown') + '</span><br>'
589
- + '<span class="meta">OS: ' + escapeHtml(c.os || 'Unknown') + '</span><br>'
590
- + '<span class="meta">CPU: ' + escapeHtml(c.cpu || 'Unknown') + '</span><br>'
591
- + '<span class="meta">RAM: ' + escapeHtml(c.ram || 'Unknown') + '</span><br>'
592
- + '<span class="meta">Time: ' + escapeHtml(when) + '</span>'
593
- + '</div>';
755
+ const title = document.createElement('strong');
756
+ title.textContent = String(c.username || 'Unknown');
757
+ card.append(title, document.createTextNode(' — ' + String(c.action || 'connected')), document.createElement('br'));
758
+ for (const field of [
759
+ ['IP', c.ip],
760
+ ['OS', c.os],
761
+ ['CPU', c.cpu],
762
+ ['RAM', c.ram],
763
+ ['Time', when]
764
+ ]) {
765
+ const meta = document.createElement('span');
766
+ meta.className = 'meta';
767
+ meta.textContent = field[0] + ': ' + String(field[1] || 'Unknown');
768
+ card.appendChild(meta);
769
+ if (field[0] !== 'Time') card.appendChild(document.createElement('br'));
770
+ }
771
+ fragment.appendChild(card);
594
772
  });
595
- container.innerHTML = html;
773
+ container.replaceChildren(fragment);
596
774
  }
597
775
 
598
- setInterval(pollClients, 5000);
599
- pollClients();
600
776
  </script>
601
777
  </body></html>`);
602
778
  });
@@ -606,7 +782,7 @@ pollClients();
606
782
  const port = server.address().port;
607
783
  const url = `http://127.0.0.1:${port}`;
608
784
  console.log(chalk.green(` ✓ Local dashboard: ${url}`));
609
- try { await open(url); } catch { }
785
+ try { await openUrl(url); } catch { }
610
786
  resolve({ url, close: () => server.close() });
611
787
  });
612
788
  });
@@ -618,18 +794,42 @@ pollClients();
618
794
  async function spawnPrivateBroker() {
619
795
  console.log(chalk.yellow('\n ⚠️ Public Broker is unreachable. Spawning Private Broker...'));
620
796
 
797
+ const serverEntrypoint = path.join(__dirname, '../server.js');
798
+ const requireFromServer = createRequire(serverEntrypoint);
799
+ const requiredBrokerPackages = ['express', 'express-rate-limit', 'helmet'];
800
+ const missingPackages = requiredBrokerPackages.filter((pkg) => {
801
+ try {
802
+ requireFromServer.resolve(pkg);
803
+ return false;
804
+ } catch {
805
+ return true;
806
+ }
807
+ });
808
+
809
+ if (missingPackages.length > 0) {
810
+ throw new Error(
811
+ `Private broker dependencies are missing: ${missingPackages.join(', ')}. `
812
+ + 'Reinstall iPingYou from its verified package before starting a private broker.'
813
+ );
814
+ }
815
+
621
816
  // 1. Spawn the broker server process
622
- const brokerProcess = execa('node', [path.join(__dirname, '../server.js')], {
817
+ const brokerProcess = execa('node', [serverEntrypoint], {
623
818
  env: { ...process.env, PORT: '4040' },
624
819
  reject: false,
625
820
  all: true,
821
+ buffer: false,
626
822
  });
627
823
  trackPID(brokerProcess.pid);
628
824
 
629
825
  let brokerExited = false;
630
- let brokerOutput = '';
826
+ const maxBrokerOutputBytes = 64 * 1024;
827
+ let brokerOutput = Buffer.alloc(0);
631
828
  brokerProcess.all?.on('data', chunk => {
632
- brokerOutput += chunk.toString();
829
+ const next = Buffer.concat([brokerOutput, Buffer.from(chunk)]);
830
+ brokerOutput = next.length > maxBrokerOutputBytes
831
+ ? next.subarray(next.length - maxBrokerOutputBytes)
832
+ : next;
633
833
  });
634
834
  brokerProcess.on('exit', () => {
635
835
  brokerExited = true;
@@ -643,7 +843,8 @@ async function spawnPrivateBroker() {
643
843
 
644
844
  await waitForValue(() => {
645
845
  if (brokerExited) {
646
- throw new Error(`Private broker exited before tunnel was ready${brokerOutput ? `: ${brokerOutput.trim()}` : ''}`);
846
+ const output = brokerOutput.toString('utf8').trim();
847
+ throw new Error(`Private broker exited before tunnel was ready${output ? `: ${output}` : ''}`);
647
848
  }
648
849
  return brokerTunnelUrl;
649
850
  }, 30000, 'Private broker tunnel startup');
@@ -753,7 +954,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
753
954
  for (const request of pending) {
754
955
  let details = {};
755
956
  try {
756
- details = JSON.parse(decrypt(request.iv, request.ciphertext, password, request.salt));
957
+ details = JSON.parse(await decryptAsync(request.iv, request.ciphertext, password, request.salt));
757
958
  } catch {
758
959
  details = { error: 'Could not decrypt request metadata' };
759
960
  }
@@ -893,10 +1094,10 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
893
1094
  } else {
894
1095
  spinner.succeed(`Found ${data.clients.length} recent connection(s):`);
895
1096
 
896
- data.clients.forEach((clientBlob, i) => {
1097
+ for (const [i, clientBlob] of data.clients.entries()) {
897
1098
  try {
898
1099
  // Decrypt using the unique salt the client generated for this payload
899
- const decrypted = decrypt(clientBlob.iv, clientBlob.ciphertext, password, clientBlob.salt);
1100
+ const decrypted = await decryptAsync(clientBlob.iv, clientBlob.ciphertext, password, clientBlob.salt);
900
1101
  const t = JSON.parse(decrypted);
901
1102
 
902
1103
  console.log(chalk.bold.blue(`\n Client #${i + 1} (${t.username})`));
@@ -910,7 +1111,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
910
1111
  console.log(chalk.yellow(`\n Client #${i + 1}: Payload decryption failed (wrong password or corrupted).`));
911
1112
  logSessionEvent('host_telemetry_decrypt_failed', { index: i + 1 }, 'warn');
912
1113
  }
913
- });
1114
+ }
914
1115
  }
915
1116
  } catch (e) {
916
1117
  spinner.fail('Could not reach broker.');
@@ -927,12 +1128,11 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
927
1128
  return waitForAction();
928
1129
 
929
1130
  case 'terminate': {
930
- const spinner = createSpinner('Terminating active SSH sessions...', networkSpinner).start();
1131
+ const spinner = createSpinner('Revoking session and closing its tunnel...', networkSpinner).start();
931
1132
  try {
932
- if (process.platform === 'win32') {
933
- await execa('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 });
934
- } else {
935
- await execa('pkill', ['-f', 'sshd:.*@'], { reject: false });
1133
+ await revokeUID(BROKER_URL, uid, sessionState.hostToken);
1134
+ tunnelProcess.kill();
1135
+ if (process.platform !== 'win32') {
936
1136
  await execa('tmux', [...tmuxSocketArgs(), 'kill-server'], { reject: false });
937
1137
  const legacySessions = await listTmuxSessions();
938
1138
  for (const session of legacySessions) {
@@ -941,7 +1141,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
941
1141
  }
942
1142
  }
943
1143
  }
944
- spinner.succeed('All client SSH sessions terminated');
1144
+ spinner.succeed('Session revoked and iPingYou-owned connections terminated');
945
1145
  logSessionEvent('host_sessions_terminated');
946
1146
  } catch {
947
1147
  spinner.warn('Could not terminate sessions (none active?)');
@@ -1118,13 +1318,13 @@ export async function startHostMode() {
1118
1318
  console.log(chalk.dim(' 🔑 Generating ephemeral SSH key for passwordless entry...'));
1119
1319
  try {
1120
1320
  const ephemeralKey = await generateEphemeralKey();
1121
- const authKeysPath = await injectPublicKey(ephemeralKey.pubKey);
1321
+ const { authKeysPath, authorizedKey } = await injectPublicKey(ephemeralKey.pubKey);
1122
1322
 
1123
1323
  serviceConfig.privateKey = ephemeralKey.privKey;
1124
1324
 
1125
1325
  addCleanupHook(async () => {
1126
1326
  console.log(chalk.dim(' Removing ephemeral public key...'));
1127
- await removePublicKey(authKeysPath, ephemeralKey.pubKey);
1327
+ await removePublicKey(authKeysPath, authorizedKey);
1128
1328
  try { await fs.promises.unlink(ephemeralKey.keyPath); } catch { }
1129
1329
  try { await fs.promises.unlink(`${ephemeralKey.keyPath}.pub`); } catch { }
1130
1330
  });