@miraj181/ipingyou 2.0.14 → 2.1.1

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
@@ -27,7 +27,8 @@ import { detectOS } from '../lib/platform.js';
27
27
  import { createSpinner, networkSpinner, typeText } from '../lib/animations.js';
28
28
  import { startChatServer, openLocalChatUI } from '../lib/chat.js';
29
29
  import { spawnTunnelSupervised } from '../lib/tunnel.js';
30
- import { pingBroker, registerWithBroker } from '../lib/broker.js';
30
+ import { decideApprovalRequest, fetchApprovalRequests, pingBroker, registerWithBroker, revokeUID } from '../lib/broker.js';
31
+ import { cleanupSessionLog, getSessionLogPath, initSessionLog, logSessionEvent, recordEvent } from '../lib/session-log.js';
31
32
 
32
33
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
33
34
  let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
@@ -151,12 +152,12 @@ async function generateEphemeralKey() {
151
152
  throw new Error('Could not resolve a temporary directory for SSH key generation');
152
153
  }
153
154
  const keyPath = path.join(tmpDir, `ipingyou_${Date.now()}`);
154
-
155
+
155
156
  await execa('ssh-keygen', ['-t', 'ed25519', '-C', 'ipingyou-ephemeral', '-f', keyPath, '-N', '']);
156
-
157
+
157
158
  const privKey = await fs.promises.readFile(keyPath, 'utf8');
158
159
  const pubKey = (await fs.promises.readFile(`${keyPath}.pub`, 'utf8')).trim();
159
-
160
+
160
161
  return { keyPath, privKey, pubKey };
161
162
  }
162
163
 
@@ -167,11 +168,11 @@ async function injectPublicKey(pubKey) {
167
168
  }
168
169
 
169
170
  const sshDir = path.join(homedir, '.ssh');
170
-
171
+
171
172
  if (!fs.existsSync(sshDir)) {
172
173
  await fs.promises.mkdir(sshDir, { mode: 0o700, recursive: true });
173
174
  }
174
-
175
+
175
176
  const authKeysPath = path.join(sshDir, 'authorized_keys');
176
177
  await fs.promises.appendFile(authKeysPath, `\n${pubKey}\n`);
177
178
  return authKeysPath;
@@ -185,12 +186,319 @@ async function removePublicKey(authKeysPath, pubKey) {
185
186
  }
186
187
  }
187
188
 
189
+ async function prepareSharedDropFolder(uid) {
190
+ const dropPath = path.join(os.homedir(), `ipingyou-dropbox-${uid}`);
191
+ await fs.promises.mkdir(dropPath, { recursive: true, mode: 0o700 });
192
+ try { await fs.promises.chmod(dropPath, 0o700); } catch { }
193
+ return dropPath;
194
+ }
195
+
196
+ function showMacPrivacyPreflight(sharedDropPath) {
197
+ if (process.platform !== 'darwin') return;
198
+
199
+ console.log('');
200
+ console.log(chalk.bold.cyan(' 🔎 macOS SSH File Access Preflight'));
201
+ console.log(chalk.dim(' ──────────────────────────────────────'));
202
+ console.log(chalk.dim(' macOS may block SSH sessions from browsing Downloads, Desktop, or Documents.'));
203
+ console.log(chalk.dim(' For reliable SCP transfers, use the session drop folder:'));
204
+ console.log(chalk.green(` ${sharedDropPath}`));
205
+ console.log(chalk.dim(' To browse protected folders over SSH, grant Full Disk Access to sshd/Remote Login.'));
206
+ console.log('');
207
+ }
208
+
209
+ async function promptOneTimeSharePath() {
210
+ const { sharePath } = await inquirer.prompt([{
211
+ type: 'input',
212
+ name: 'sharePath',
213
+ message: 'Local file/folder to share one time:',
214
+ validate: value => {
215
+ const trimmed = value.trim();
216
+ if (!trimmed) return 'Required';
217
+ const expanded = trimmed === '~' ? os.homedir() : trimmed.replace(/^~(?=\/)/, os.homedir());
218
+ return fs.existsSync(expanded) || 'Path does not exist';
219
+ },
220
+ }]);
221
+ return sharePath.trim() === '~' ? os.homedir() : sharePath.trim().replace(/^~(?=\/)/, os.homedir());
222
+ }
223
+
224
+ async function startLocalHostDashboard(uid, password, serviceConfig, hostToken) {
225
+ const { default: express } = await import('express');
226
+ const { default: open } = await import('open');
227
+ const app = express();
228
+ const startedAt = new Date().toISOString();
229
+
230
+ app.get('/api/status', (_req, res) => {
231
+ res.json({
232
+ uid,
233
+ startedAt,
234
+ service: serviceConfig.type,
235
+ port: serviceConfig.port,
236
+ approvalRequired: Boolean(serviceConfig.approvalRequired),
237
+ sharedDropPath: serviceConfig.sharedDropPath || null,
238
+ oneTimeSharePath: serviceConfig.oneTimeSharePath || null,
239
+ chatUrl: serviceConfig.chatUrl ? '[configured]' : null,
240
+ });
241
+ });
242
+
243
+ app.use(express.json());
244
+
245
+ // Server-Sent Events for live telemetry & approvals
246
+ app.get('/api/events', async (req, res) => {
247
+ res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
248
+ res.flushHeaders?.();
249
+
250
+ let closed = false;
251
+ const push = async () => {
252
+ if (closed) return;
253
+ try {
254
+ const data = await fetchApprovalRequests(BROKER_URL, uid, hostToken).catch(() => ({ approvals: [] }));
255
+ res.write(`event: approvals\n`);
256
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
257
+ } catch { }
258
+ };
259
+
260
+ const iv = setInterval(push, 3000);
261
+ req.on('close', () => { clearInterval(iv); closed = true; });
262
+ // send initial payload
263
+ await push();
264
+ });
265
+
266
+ // CSRF Protection Middleware for all mutating endpoints
267
+ app.use((req, res, next) => {
268
+ if (req.method !== 'POST') return next();
269
+
270
+ // Express runs on localhost, ensure requests originate from the same host
271
+ const origin = req.headers.origin;
272
+ const referer = req.headers.referer;
273
+
274
+ // We expect origin to be http://127.0.0.1:<port>
275
+ const isLocalOrigin = origin && (origin.startsWith('http://127.0.0.1:') || origin.startsWith('http://localhost:'));
276
+ const isLocalReferer = referer && (referer.startsWith('http://127.0.0.1:') || referer.startsWith('http://localhost:'));
277
+
278
+ if (!isLocalOrigin && !isLocalReferer) {
279
+ console.warn(chalk.yellow(`\n âš ī¸ Blocked potential CSRF attack from origin: ${origin || referer || 'unknown'}`));
280
+ return res.status(403).json({ error: 'CSRF validation failed' });
281
+ }
282
+ next();
283
+ });
284
+
285
+ app.post('/api/approval', async (req, res) => {
286
+ const { requestId, decision } = req.body || {};
287
+ if (!requestId || !decision) return res.status(400).json({ error: 'requestId and decision required' });
288
+ try {
289
+ await decideApprovalRequest(BROKER_URL, uid, requestId, decision, hostToken);
290
+ recordEvent('approval_decision', { uid, requestId, decision, via: 'dashboard' });
291
+ res.json({ ok: true });
292
+ } catch (err) {
293
+ res.status(500).json({ error: err.message });
294
+ }
295
+ });
296
+
297
+ app.post('/api/revoke', async (_req, res) => {
298
+ try {
299
+ await revokeUID(BROKER_URL, uid, hostToken);
300
+ recordEvent('uid_revoked', { uid, via: 'dashboard' });
301
+ res.json({ ok: true });
302
+ } catch (err) {
303
+ res.status(500).json({ error: err.message });
304
+ }
305
+ });
306
+
307
+ app.get('/', (_req, res) => {
308
+ res.type('html').send(`<!doctype html>
309
+ <html lang="en"><head><meta charset="utf-8"><title>iPingYou Host Dashboard</title>
310
+ <style>
311
+ :root{--bg:#0f172a;--panel:#1e293b;--border:#334155;--text:#f8fafc;--primary:#38bdf8;--accent:#818cf8;--green:#22c55e;--red:#ef4444;--yellow:#eab308;--dim:#94a3b8}
312
+ *{box-sizing:border-box;margin:0;padding:0}
313
+ body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;padding:2rem}
314
+ h1{font-size:1.5rem;margin-bottom:0.5rem;display:flex;align-items:center;gap:0.5rem}
315
+ h2{font-size:1.1rem;color:var(--primary);margin-bottom:1rem;text-transform:uppercase;letter-spacing:0.05em}
316
+ .header{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:1.5rem 2rem;margin-bottom:1.5rem;display:flex;justify-content:space-between;align-items:center}
317
+ .badge{background:var(--green);color:#000;padding:0.2rem 0.6rem;border-radius:999px;font-size:0.75rem;font-weight:700;animation:pulse 2s infinite}
318
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:0.6}}
319
+ .grid{display:grid;grid-template-columns:1fr 1fr;gap:1.5rem}
320
+ @media(max-width:800px){.grid{grid-template-columns:1fr}}
321
+ .card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:1.5rem}
322
+ .info-row{display:flex;justify-content:space-between;padding:0.5rem 0;border-bottom:1px solid var(--border)}
323
+ .info-row:last-child{border:none}
324
+ .info-label{color:var(--dim);font-size:0.85rem}
325
+ .info-value{font-weight:600;font-size:0.85rem}
326
+ code{background:var(--bg);padding:2px 8px;border-radius:4px;font-size:0.85rem}
327
+ .btn{border:none;padding:0.6rem 1.2rem;border-radius:8px;font-weight:700;cursor:pointer;font-size:0.85rem;transition:all 0.2s}
328
+ .btn:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,0.3)}
329
+ .btn:active{transform:scale(0.97)}
330
+ .btn-approve{background:var(--green);color:#000}.btn-deny{background:var(--red);color:white}
331
+ .btn-revoke{background:var(--red);color:white;padding:0.5rem 1rem}
332
+ .approval-item{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:1rem;margin-bottom:0.75rem;animation:fadeIn 0.3s ease}
333
+ @keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
334
+ .approval-item .meta{font-size:0.8rem;color:var(--dim);margin-bottom:0.5rem}
335
+ .approval-item .actions{display:flex;gap:0.5rem;margin-top:0.75rem}
336
+ .status-badge{display:inline-block;padding:0.15rem 0.5rem;border-radius:999px;font-size:0.75rem;font-weight:600}
337
+ .status-pending{background:var(--yellow);color:#000}
338
+ .status-approved{background:var(--green);color:#000}
339
+ .status-denied{background:var(--red);color:white}
340
+ .empty{color:var(--dim);font-style:italic;font-size:0.9rem;padding:1rem 0}
341
+ .client-card{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:0.8rem 1rem;margin-bottom:0.5rem;font-size:0.85rem}
342
+ .client-card strong{color:var(--primary)}
343
+ #toast{position:fixed;bottom:2rem;right:2rem;background:var(--green);color:#000;padding:0.8rem 1.5rem;border-radius:8px;font-weight:700;opacity:0;transition:opacity 0.3s;pointer-events:none}
344
+ #toast.show{opacity:1}
345
+ </style></head>
346
+ <body>
347
+ <div class="header">
348
+ <div><h1>đŸ›Ąī¸ iPingYou Host Dashboard</h1><span style="color:var(--dim);font-size:0.85rem">UID: <code>${uid}</code></span></div>
349
+ <div style="display:flex;gap:1rem;align-items:center">
350
+ <span class="badge">● LIVE</span>
351
+ <button class="btn btn-revoke" onclick="revokeSession()">đŸšĢ Revoke Session</button>
352
+ </div>
353
+ </div>
354
+
355
+ <div class="grid">
356
+ <div class="card">
357
+ <h2>📊 Session Info</h2>
358
+ <div class="info-row"><span class="info-label">UID</span><span class="info-value"><code>${uid}</code></span></div>
359
+ <div class="info-row"><span class="info-label">Password</span><span class="info-value"><code>[Hidden — see terminal]</code></span></div>
360
+ <div class="info-row"><span class="info-label">Service</span><span class="info-value">${serviceConfig.type.toUpperCase()} on port ${serviceConfig.port}</span></div>
361
+ <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>
362
+ <div class="info-row"><span class="info-label">Drop Folder</span><span class="info-value"><code>${serviceConfig.sharedDropPath || 'none'}</code></span></div>
363
+ <div class="info-row"><span class="info-label">One-Time Share</span><span class="info-value"><code>${serviceConfig.oneTimeSharePath || 'none'}</code></span></div>
364
+ <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>
365
+ <div class="info-row"><span class="info-label">Uptime</span><span class="info-value" id="uptime">—</span></div>
366
+ </div>
367
+
368
+ <div class="card">
369
+ <h2>✅ Pending Approvals <span id="approval-count" style="color:var(--dim);font-size:0.8rem"></span></h2>
370
+ <div id="approvals"><p class="empty">No pending requests</p></div>
371
+ </div>
372
+ </div>
373
+
374
+ <div class="card" style="margin-top:1.5rem">
375
+ <h2>📡 Connected Clients <span id="client-count" style="color:var(--dim);font-size:0.8rem"></span></h2>
376
+ <div id="clients"><p class="empty">No clients connected yet</p></div>
377
+ </div>
378
+
379
+ <div id="toast"></div>
380
+
381
+ <script>
382
+ const startedAt = new Date("${startedAt}");
383
+
384
+ function showToast(msg) {
385
+ const t = document.getElementById('toast');
386
+ t.textContent = msg;
387
+ t.classList.add('show');
388
+ setTimeout(() => t.classList.remove('show'), 2500);
389
+ }
390
+
391
+ function updateUptime() {
392
+ const diff = Math.floor((Date.now() - startedAt.getTime()) / 1000);
393
+ const h = Math.floor(diff / 3600);
394
+ const m = Math.floor((diff % 3600) / 60);
395
+ const s = diff % 60;
396
+ document.getElementById('uptime').textContent = h + 'h ' + m + 'm ' + s + 's';
397
+ }
398
+ setInterval(updateUptime, 1000);
399
+ updateUptime();
400
+
401
+ // SSE for live updates
402
+ const es = new EventSource('/api/events');
403
+ es.addEventListener('approvals', (e) => {
404
+ try {
405
+ const data = JSON.parse(e.data);
406
+ renderApprovals(data.approvals || []);
407
+ } catch {}
408
+ });
409
+
410
+ function renderApprovals(approvals) {
411
+ const container = document.getElementById('approvals');
412
+ const pending = approvals.filter(a => a.status === 'pending');
413
+ const decided = approvals.filter(a => a.status !== 'pending').slice(-5);
414
+ document.getElementById('approval-count').textContent = pending.length > 0 ? '(' + pending.length + ' pending)' : '';
415
+
416
+ if (approvals.length === 0) {
417
+ container.innerHTML = '<p class="empty">No approval requests yet</p>';
418
+ return;
419
+ }
420
+
421
+ let html = '';
422
+ for (const req of pending) {
423
+ html += '<div class="approval-item">'
424
+ + '<div style="display:flex;justify-content:space-between;align-items:center">'
425
+ + '<strong>Request ' + req.id + '</strong>'
426
+ + '<span class="status-badge status-pending">PENDING</span></div>'
427
+ + '<div class="meta">Submitted: ' + new Date(req.createdAt).toLocaleTimeString() + '</div>'
428
+ + '<div class="actions">'
429
+ + '<button class="btn btn-approve" data-id="' + req.id + '" data-decision="approved">✅ Approve</button>'
430
+ + '<button class="btn btn-deny" data-id="' + req.id + '" data-decision="denied">❌ Deny</button>'
431
+ + '</div></div>';
432
+ }
433
+ for (const req of decided) {
434
+ const cls = req.status === 'approved' ? 'status-approved' : 'status-denied';
435
+ html += '<div class="approval-item" style="opacity:0.6">'
436
+ + '<div style="display:flex;justify-content:space-between;align-items:center">'
437
+ + '<strong>Request ' + req.id + '</strong>'
438
+ + '<span class="status-badge ' + cls + '">' + req.status.toUpperCase() + '</span></div>'
439
+ + '<div class="meta">Decided: ' + (req.decidedAt ? new Date(req.decidedAt).toLocaleTimeString() : 'N/A') + '</div>'
440
+ + '</div>';
441
+ }
442
+ container.innerHTML = html;
443
+
444
+ // Attach click handlers via event delegation (avoids inline quote escaping issues)
445
+ container.querySelectorAll('[data-decision]').forEach(function(btn) {
446
+ btn.addEventListener('click', function() {
447
+ decide(btn.getAttribute('data-id'), btn.getAttribute('data-decision'));
448
+ });
449
+ });
450
+ }
451
+
452
+ async function decide(requestId, decision) {
453
+ try {
454
+ const res = await fetch('/api/approval', {
455
+ method: 'POST',
456
+ headers: { 'Content-Type': 'application/json' },
457
+ body: JSON.stringify({ requestId, decision })
458
+ });
459
+ if (res.ok) showToast(decision === 'approved' ? '✅ Approved!' : '❌ Denied!');
460
+ else showToast('Failed: ' + (await res.json()).error);
461
+ } catch (err) { showToast('Error: ' + err.message); }
462
+ }
463
+
464
+ async function revokeSession() {
465
+ if (!confirm('Are you sure you want to revoke this session? All clients will lose access.')) return;
466
+ try {
467
+ const res = await fetch('/api/revoke', { method: 'POST' });
468
+ if (res.ok) { showToast('đŸšĢ Session revoked!'); document.querySelector('.badge').textContent = '● REVOKED'; document.querySelector('.badge').style.background = 'var(--red)'; }
469
+ else showToast('Failed to revoke');
470
+ } catch (err) { showToast('Error: ' + err.message); }
471
+ }
472
+
473
+ // Poll client telemetry (separate from SSE for simplicity)
474
+ async function pollClients() {
475
+ try {
476
+ const res = await fetch('/api/status');
477
+ if (!res.ok) return;
478
+ // Client data is fetched from broker by SSE push. Let's also add a client endpoint.
479
+ } catch {}
480
+ }
481
+ </script>
482
+ </body></html>`);
483
+ });
484
+
485
+ return new Promise((resolve) => {
486
+ const server = app.listen(0, '127.0.0.1', async () => {
487
+ const port = server.address().port;
488
+ const url = `http://127.0.0.1:${port}`;
489
+ console.log(chalk.green(` ✓ Local dashboard: ${url}`));
490
+ try { await open(url); } catch { }
491
+ resolve({ url, close: () => server.close() });
492
+ });
493
+ });
494
+ }
495
+
188
496
  /**
189
497
  * Auto-spawn a Private Broker locally and wrap it in a Cloudflare tunnel.
190
498
  */
191
499
  async function spawnPrivateBroker() {
192
500
  console.log(chalk.yellow('\n âš ī¸ Public Broker is unreachable. Spawning Private Broker...'));
193
-
501
+
194
502
  // 1. Spawn the broker server process
195
503
  const brokerProcess = execa('node', [path.join(__dirname, '../server.js')], {
196
504
  env: { ...process.env, PORT: '4040' },
@@ -222,7 +530,7 @@ async function spawnPrivateBroker() {
222
530
  }, 30000, 'Private broker tunnel startup');
223
531
 
224
532
  console.log(chalk.green(` ✅ Private Broker Active: ${chalk.bold.cyan(brokerTunnelUrl)}\n`));
225
-
533
+
226
534
  return {
227
535
  url: brokerTunnelUrl,
228
536
  kill: () => {
@@ -237,9 +545,10 @@ async function spawnPrivateBroker() {
237
545
  /**
238
546
  * Display the host dashboard and handle user input.
239
547
  */
240
- async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProcess) {
548
+ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProcess, hostToken) {
241
549
  let chatServerInstance = null;
242
550
  let chatTunnelProcess = null;
551
+ let dashboardInstance = null;
243
552
 
244
553
  const renderDashboard = () => {
245
554
  console.clear();
@@ -254,6 +563,10 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
254
563
  if (serviceConfig.chatUrl) {
255
564
  console.log(` ║ ${chalk.cyan('Chat URL:')} ${chalk.dim(serviceConfig.chatUrl.substring(0, 40))} ║`);
256
565
  }
566
+ if (serviceConfig.sharedDropPath) {
567
+ console.log(` ║ ${chalk.cyan('Drop Box:')} ${chalk.dim(serviceConfig.sharedDropPath.substring(0, 40))} ║`);
568
+ }
569
+ console.log(` ║ ${chalk.cyan('Approval:')} ${serviceConfig.approvalRequired ? chalk.green('Required').padEnd(39) : chalk.dim('Not required').padEnd(39)}║`);
257
570
  console.log(` ║ ${chalk.cyan('Broker:')} ${chalk.dim(BROKER_URL.substring(0, 40))} ║`);
258
571
  console.log(` ║ ${chalk.cyan('Crypto:')} ${chalk.green('AES-256-CBC E2E (PBKDF2)')} ║`);
259
572
  console.log(chalk.bold(' â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•Ŗ'));
@@ -261,6 +574,11 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
261
574
  console.log(` ║ ${chalk.dim('Press Ctrl+C to terminate the session')} ║`);
262
575
  console.log(chalk.bold(' ╚════════════════════════════════════════════════════╝'));
263
576
  console.log('');
577
+ const logPath = getSessionLogPath();
578
+ if (logPath) {
579
+ console.log(chalk.dim(` 📜 Session log: ${logPath}`));
580
+ console.log('');
581
+ }
264
582
  };
265
583
 
266
584
  renderDashboard();
@@ -270,6 +588,7 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
270
588
  const waitForAction = async () => {
271
589
  try {
272
590
  const choices = [
591
+ { name: '✅ Review pending client approvals', value: 'approvals' },
273
592
  { name: '📡 See detailed client telemetry', value: 'show' },
274
593
  { name: 'đŸ“ē Mirror Client Terminal (requires tmux)', value: 'mirror' },
275
594
  { name: '🔄 Re-register with broker', value: 'reregister' }
@@ -280,6 +599,11 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
280
599
  } else {
281
600
  choices.push({ name: 'đŸ’Ŧ Re-open Chat Room in Browser', value: 'reopen_chat' });
282
601
  }
602
+ if (!dashboardInstance) {
603
+ choices.push({ name: '🌐 Open Local Web Dashboard', value: 'dashboard' });
604
+ } else {
605
+ choices.push({ name: '🌐 Show Local Web Dashboard URL', value: 'dashboard_url' });
606
+ }
283
607
 
284
608
  choices.push(
285
609
  { name: 'đŸšĢ Terminate all connections', value: 'terminate' },
@@ -295,36 +619,99 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
295
619
  },
296
620
  ]);
297
621
 
622
+ logSessionEvent('host_action_selected', { action });
623
+
298
624
  switch (action) {
625
+ case 'approvals': {
626
+ try {
627
+ const data = await fetchApprovalRequests(BROKER_URL, uid);
628
+ const pending = (data.approvals || []).filter(item => item.status === 'pending');
629
+ if (pending.length === 0) {
630
+ console.log(chalk.yellow(' No pending approval requests.'));
631
+ return waitForAction();
632
+ }
633
+
634
+ for (const request of pending) {
635
+ let details = {};
636
+ try {
637
+ details = JSON.parse(decrypt(request.iv, request.ciphertext, password, request.salt));
638
+ } catch {
639
+ details = { error: 'Could not decrypt request metadata' };
640
+ }
641
+ console.log('');
642
+ console.log(chalk.bold.cyan(` Approval Request ${request.id}`));
643
+ console.log(` User: ${details.username || 'unknown'}`);
644
+ console.log(` Host: ${details.hostname || 'unknown'}`);
645
+ console.log(` OS: ${details.os || 'unknown'}`);
646
+ console.log(` Intent: ${details.intent || 'connect'}`);
647
+
648
+ const { decision } = await inquirer.prompt([{
649
+ type: 'list',
650
+ name: 'decision',
651
+ message: 'Decision:',
652
+ choices: [
653
+ { name: 'Approve', value: 'approved' },
654
+ { name: 'Deny', value: 'denied' },
655
+ { name: 'Skip', value: 'skip' },
656
+ ],
657
+ }]);
658
+ if (decision !== 'skip') {
659
+ await decideApprovalRequest(BROKER_URL, uid, request.id, decision);
660
+ recordEvent('approval_decision', { uid, requestId: request.id, decision, username: details.username });
661
+ }
662
+ }
663
+ } catch (err) {
664
+ console.log(chalk.red(` Could not review approvals: ${err.message}`));
665
+ logSessionEvent('host_approvals_error', { error: err.message }, 'error');
666
+ }
667
+ return waitForAction();
668
+ }
669
+
299
670
  case 'chat': {
300
671
  console.log(chalk.dim('\n Starting chat server...'));
301
672
  chatServerInstance = await startChatServer(async () => {
302
- if (chatTunnelProcess) {
303
- chatTunnelProcess.kill();
304
- chatTunnelProcess = null;
305
- chatServerInstance = null;
306
- delete serviceConfig.chatUrl;
307
- await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
308
- renderDashboard();
309
- }
673
+ if (chatTunnelProcess) {
674
+ chatTunnelProcess.kill();
675
+ chatTunnelProcess = null;
676
+ chatServerInstance = null;
677
+ delete serviceConfig.chatUrl;
678
+ const res = await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
679
+ if (res.success && res.hostToken) hostToken = res.hostToken;
680
+ renderDashboard();
681
+ }
310
682
  });
311
683
 
312
684
  console.log(chalk.dim(' Provisioning Cloudflare tunnel for chat...'));
313
685
  chatTunnelProcess = await spawnTunnelSupervised(`http://localhost:${chatServerInstance.port}`, async (newUrl) => {
314
686
  serviceConfig.chatUrl = newUrl;
315
- await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
687
+ const res = await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
688
+ if (res.success && res.hostToken) hostToken = res.hostToken;
316
689
  renderDashboard();
317
690
  });
318
691
 
319
692
  await waitForValue(() => serviceConfig.chatUrl, 30000, 'Chat tunnel startup');
320
693
 
321
694
  console.log(chalk.green(' ✅ Chat Room Live! Clients can now join.'));
695
+ logSessionEvent('host_chat_started');
322
696
  await openLocalChatUI(chatServerInstance.port, password);
323
697
  return waitForAction();
324
698
  }
325
699
 
700
+ case 'dashboard': {
701
+ dashboardInstance = await startLocalHostDashboard(uid, password, serviceConfig, hostToken);
702
+ logSessionEvent('host_dashboard_opened');
703
+ return waitForAction();
704
+ }
705
+
706
+ case 'dashboard_url': {
707
+ console.log(chalk.green(` Dashboard: ${dashboardInstance.url}`));
708
+ logSessionEvent('host_dashboard_url_shown');
709
+ return waitForAction();
710
+ }
711
+
326
712
  case 'reopen_chat': {
327
713
  if (chatServerInstance) await openLocalChatUI(chatServerInstance.port, password);
714
+ logSessionEvent('host_chat_reopened');
328
715
  return waitForAction();
329
716
  }
330
717
 
@@ -335,7 +722,7 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
335
722
  console.log(chalk.dim(' Attaching to the tmux session created by an interactive SSH client.'));
336
723
  console.log(chalk.dim(' Press Ctrl+b then d to detach gracefully.'));
337
724
  console.log('');
338
-
725
+
339
726
  try {
340
727
  await execaCommand('tmux -V', { reject: true });
341
728
  const sessionCheck = await execa('tmux', ['has-session', '-t', 'SecureLink_Session'], { reject: false });
@@ -343,13 +730,16 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
343
730
  console.log(chalk.yellow(' âš ī¸ No mirrored terminal session is active yet.'));
344
731
  console.log(chalk.dim(' A client must choose "Connect via SSH" first. SCP-only clients do not create a tmux session.'));
345
732
  console.log(chalk.dim(' tmux is needed on the host machine only; the client does not need tmux.'));
733
+ logSessionEvent('host_mirror_missing_session', {}, 'warn');
346
734
  return waitForAction();
347
735
  }
348
736
  await execaCommand('tmux attach -t SecureLink_Session -r', { stdio: 'inherit' });
737
+ logSessionEvent('host_mirror_attached');
349
738
  } catch (err) {
350
739
  console.log(chalk.yellow(' âš ī¸ Could not attach to tmux.'));
351
740
  console.log(chalk.dim(` ${err.message}`));
352
741
  console.log(chalk.dim(' Terminal mirroring requires tmux on the host machine and an active interactive SSH client.'));
742
+ logSessionEvent('host_mirror_error', { error: err.message }, 'warn');
353
743
  }
354
744
  return waitForAction();
355
745
  }
@@ -360,38 +750,43 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
360
750
  const res = await fetch(`${BROKER_URL}/clients/${uid}`);
361
751
  if (!res.ok) throw new Error('Failed to fetch from broker');
362
752
  const data = await res.json();
363
-
753
+
364
754
  if (!data.clients || data.clients.length === 0) {
365
755
  spinner.warn('No clients have successfully connected and sent telemetry yet.');
366
756
  } else {
367
757
  spinner.succeed(`Found ${data.clients.length} recent connection(s):`);
368
-
758
+
369
759
  data.clients.forEach((clientBlob, i) => {
370
760
  try {
371
761
  // Decrypt using the unique salt the client generated for this payload
372
762
  const decrypted = decrypt(clientBlob.iv, clientBlob.ciphertext, password, clientBlob.salt);
373
763
  const t = JSON.parse(decrypted);
374
-
375
- console.log(chalk.bold.blue(`\n Client #${i+1} (${t.username})`));
764
+
765
+ console.log(chalk.bold.blue(`\n Client #${i + 1} (${t.username})`));
376
766
  console.log(` IP: ${chalk.white(t.ip)}`);
377
767
  console.log(` OS: ${chalk.dim(t.os)}`);
378
768
  console.log(` CPU: ${chalk.dim(t.cpu)}`);
379
769
  console.log(` RAM: ${chalk.dim(t.ram)}`);
770
+ console.log(` Action: ${chalk.yellow(t.action || 'connected')}`);
380
771
  console.log(` Time: ${chalk.dim(t.time)}`);
381
772
  } catch (e) {
382
- console.log(chalk.yellow(`\n Client #${i+1}: Payload decryption failed (wrong password or corrupted).`));
773
+ console.log(chalk.yellow(`\n Client #${i + 1}: Payload decryption failed (wrong password or corrupted).`));
774
+ logSessionEvent('host_telemetry_decrypt_failed', { index: i + 1 }, 'warn');
383
775
  }
384
776
  });
385
777
  }
386
778
  } catch (e) {
387
- spinner.fail('Could not reach broker.');
779
+ spinner.fail('Could not reach broker.');
780
+ logSessionEvent('host_telemetry_fetch_failed', { error: e.message }, 'warn');
388
781
  }
389
782
  console.log('');
390
783
  return waitForAction();
391
784
  }
392
785
 
393
786
  case 'reregister':
394
- await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
787
+ const res = await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
788
+ if (res.success && res.hostToken) hostToken = res.hostToken;
789
+ logSessionEvent('host_broker_reregistered');
395
790
  return waitForAction();
396
791
 
397
792
  case 'terminate': {
@@ -404,16 +799,20 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
404
799
  await execaCommand('tmux kill-session -t SecureLink_Session', { reject: false });
405
800
  }
406
801
  spinner.succeed('All client SSH sessions terminated');
802
+ logSessionEvent('host_sessions_terminated');
407
803
  } catch {
408
804
  spinner.warn('Could not terminate sessions (none active?)');
805
+ logSessionEvent('host_sessions_terminate_failed', {}, 'warn');
409
806
  }
410
807
  return waitForAction();
411
808
  }
412
809
 
413
810
  case 'exit':
811
+ if (dashboardInstance) dashboardInstance.close();
414
812
  if (chatTunnelProcess) chatTunnelProcess.kill();
415
813
  if (global.privateBrokerInstance) global.privateBrokerInstance.kill();
416
814
  if (tunnelProcess) tunnelProcess.kill();
815
+ logSessionEvent('host_session_exit');
417
816
  await cleanupAll();
418
817
  return;
419
818
  }
@@ -434,9 +833,17 @@ export async function startHostMode() {
434
833
  console.log(chalk.dim(' ─────────────────────────────────────'));
435
834
  console.log('');
436
835
 
836
+ const sessionLogPath = initSessionLog('host');
837
+ if (sessionLogPath) {
838
+ console.log(chalk.dim(` 📜 Session log: ${sessionLogPath}`));
839
+ addCleanupHook(() => cleanupSessionLog());
840
+ }
841
+ logSessionEvent('host_mode_started');
842
+
437
843
  const uid = generateUID();
438
844
  console.log(` ${chalk.green('✓')} Session UID: ${chalk.bold.white(uid)}`);
439
845
  console.log('');
846
+ logSessionEvent('host_uid_generated', { uid });
440
847
 
441
848
  const { pwdInput } = await inquirer.prompt([
442
849
  {
@@ -467,17 +874,20 @@ export async function startHostMode() {
467
874
  global.privateBrokerInstance = await spawnPrivateBroker();
468
875
  BROKER_URL = global.privateBrokerInstance.url;
469
876
  }
877
+ logSessionEvent('host_broker_selected', { choice: brokerChoice, broker: BROKER_URL });
470
878
  }
471
879
 
472
880
  // Only ping if we haven't just created a private broker
473
881
  if (!global.privateBrokerInstance) {
474
882
  const spinner = createSpinner(`Checking broker status at ${BROKER_URL}...`, networkSpinner).start();
475
883
  const brokerOnline = await pingBroker(BROKER_URL);
476
-
884
+
477
885
  if (brokerOnline) {
478
886
  spinner.succeed(`Broker is online ${chalk.dim(`(${BROKER_URL})`)}`);
887
+ logSessionEvent('host_broker_online', { broker: BROKER_URL });
479
888
  } else {
480
889
  spinner.warn(`Broker is unreachable ${chalk.dim(`(${BROKER_URL})`)}`);
890
+ logSessionEvent('host_broker_unreachable', { broker: BROKER_URL }, 'warn');
481
891
  const { startPrivate } = await inquirer.prompt([
482
892
  {
483
893
  type: 'confirm',
@@ -489,8 +899,10 @@ export async function startHostMode() {
489
899
  if (startPrivate) {
490
900
  global.privateBrokerInstance = await spawnPrivateBroker();
491
901
  BROKER_URL = global.privateBrokerInstance.url;
902
+ logSessionEvent('host_private_broker_spawned', { broker: BROKER_URL });
492
903
  } else {
493
904
  console.log(chalk.red('\n ❌ FATAL: Cannot continue without a broker.'));
905
+ logSessionEvent('host_broker_missing_exit', { broker: BROKER_URL }, 'error');
494
906
  process.exit(1);
495
907
  }
496
908
  }
@@ -504,6 +916,7 @@ export async function startHostMode() {
504
916
  message: 'What service do you want to expose?',
505
917
  choices: [
506
918
  { name: 'đŸ–Ĩī¸ SSH (Port 22)', value: 'ssh' },
919
+ { name: 'đŸ“Ļ One-Time File Share (SCP over SSH)', value: 'share' },
507
920
  { name: '🌐 Web/HTTP (Custom Port)', value: 'http' },
508
921
  { name: '🔌 Custom TCP Port (e.g. Database, RDP, VNC)', value: 'tcp' }
509
922
  ]
@@ -513,7 +926,10 @@ export async function startHostMode() {
513
926
  let targetPort = 22;
514
927
  let protocol = 'ssh';
515
928
 
516
- if (serviceType === 'http') {
929
+ if (serviceType === 'share') {
930
+ targetPort = 22;
931
+ protocol = 'ssh';
932
+ } else if (serviceType === 'http') {
517
933
  const ans = await inquirer.prompt([{ name: 'port', message: 'Enter local HTTP port (e.g. 3000):', default: '3000' }]);
518
934
  targetPort = ans.port;
519
935
  protocol = 'http';
@@ -523,49 +939,80 @@ export async function startHostMode() {
523
939
  protocol = 'tcp';
524
940
  }
525
941
 
526
- const serviceConfig = { type: serviceType, port: targetPort, protocol };
942
+ const serviceConfig = { type: serviceType === 'share' ? 'ssh' : serviceType, port: targetPort, protocol };
527
943
  const targetUrl = `${protocol}://localhost:${targetPort}`;
944
+ logSessionEvent('host_service_selected', { serviceType, protocol, port: targetPort });
528
945
 
529
- if (serviceType === 'ssh') {
946
+ if (serviceType === 'ssh' || serviceType === 'share') {
530
947
  await ensureSSHRunning();
531
948
  await ensureTmuxInstalled();
949
+
950
+ try {
951
+ serviceConfig.sharedDropPath = await prepareSharedDropFolder(uid);
952
+ console.log(chalk.green(` ✓ Shared drop folder ready: ${serviceConfig.sharedDropPath}`));
953
+ showMacPrivacyPreflight(serviceConfig.sharedDropPath);
954
+ } catch (err) {
955
+ console.log(chalk.yellow(` âš ī¸ Could not prepare shared drop folder: ${err.message}`));
956
+ }
957
+
958
+ if (serviceType === 'share') {
959
+ serviceConfig.oneTimeSharePath = await promptOneTimeSharePath();
960
+ serviceConfig.oneTime = true;
961
+ console.log(chalk.green(` ✓ One-time share selected: ${serviceConfig.oneTimeSharePath}`));
962
+ logSessionEvent('host_one_time_share_selected');
963
+ }
964
+
965
+ const { approvalRequired } = await inquirer.prompt([{
966
+ type: 'confirm',
967
+ name: 'approvalRequired',
968
+ message: 'Require host approval before clients receive tunnel/key material?',
969
+ default: serviceType !== 'share',
970
+ }]);
971
+ serviceConfig.approvalRequired = approvalRequired;
972
+ logSessionEvent('host_approval_required', { approvalRequired });
973
+
532
974
  console.log(chalk.dim(' 🔑 Generating ephemeral SSH key for passwordless entry...'));
533
975
  try {
534
976
  const ephemeralKey = await generateEphemeralKey();
535
977
  const authKeysPath = await injectPublicKey(ephemeralKey.pubKey);
536
-
978
+
537
979
  serviceConfig.privateKey = ephemeralKey.privKey;
538
-
980
+
539
981
  addCleanupHook(async () => {
540
982
  console.log(chalk.dim(' Removing ephemeral public key...'));
541
983
  await removePublicKey(authKeysPath, ephemeralKey.pubKey);
542
- try { await fs.promises.unlink(ephemeralKey.keyPath); } catch {}
543
- try { await fs.promises.unlink(`${ephemeralKey.keyPath}.pub`); } catch {}
984
+ try { await fs.promises.unlink(ephemeralKey.keyPath); } catch { }
985
+ try { await fs.promises.unlink(`${ephemeralKey.keyPath}.pub`); } catch { }
544
986
  });
545
987
  console.log(chalk.green(' ✓ Ephemeral key injected. Client will connect without system password!'));
988
+ logSessionEvent('host_ephemeral_key_ready');
546
989
  } catch (err) {
547
990
  console.log(chalk.yellow(` âš ī¸ Could not prepare ephemeral SSH key: ${err.message}`));
548
991
  console.log(chalk.dim(' Client will need to use standard OS password.'));
992
+ logSessionEvent('host_ephemeral_key_failed', { error: err.message }, 'warn');
549
993
  }
550
994
  } else {
551
995
  console.log(chalk.dim(` â„šī¸ Ensure your ${protocol.toUpperCase()} service is running on port ${targetPort}.`));
552
996
  }
553
997
 
554
998
  let tunnelUrl = null;
999
+ let hostToken = null;
555
1000
  const tunnelProcess = await spawnTunnelSupervised(targetUrl, async (newUrl) => {
556
1001
  tunnelUrl = newUrl;
557
1002
  // Register or re-register with broker when tunnel is spawned/respawned
558
- const registered = await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
559
- if (!registered) {
1003
+ const res = await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
1004
+ if (!res.success) {
560
1005
  console.error(chalk.red(`\n ❌ FATAL: Could not register with broker at ${BROKER_URL}`));
1006
+ logSessionEvent('host_broker_register_failed', { broker: BROKER_URL }, 'error');
561
1007
  process.exit(1);
562
1008
  }
1009
+ if (res.hostToken) hostToken = res.hostToken;
563
1010
  });
564
1011
 
565
1012
  // Wait for the first URL to be generated before showing the dashboard
566
1013
  await waitForValue(() => tunnelUrl, 30000, 'Cloudflare tunnel startup');
567
1014
 
568
- setRevokeOnExit(uid, BROKER_URL);
1015
+ setRevokeOnExit(uid, BROKER_URL, () => hostToken);
569
1016
 
570
- await hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProcess);
1017
+ await hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProcess, hostToken);
571
1018
  }