@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/README.md +9 -4
- package/SECURITY.md +21 -0
- package/package.json +13 -13
- package/src/cli.js +55 -22
- package/src/lib/ai/safety.js +69 -12
- package/src/lib/broker.js +5 -5
- package/src/lib/chat.js +20 -6
- package/src/lib/checksum.js +22 -2
- package/src/lib/cleanup.js +60 -93
- package/src/lib/crypto.js +27 -0
- package/src/lib/open-url.js +28 -0
- package/src/lib/platform.js +38 -481
- package/src/lib/secure-print.js +7 -1
- package/src/lib/session-log.js +78 -3
- package/src/lib/socket-firewall.js +34 -0
- package/src/lib/ssh.js +32 -6
- package/src/lib/tunnel.js +1 -0
- package/src/lib/uid.js +6 -3
- package/src/lib/worker-runtime.js +81 -0
- package/src/lib/workers/crypto-checksum-worker.js +70 -0
- package/src/modes/ai.js +104 -31
- package/src/modes/client.js +20 -13
- package/src/modes/host.js +316 -116
- package/src/server.js +95 -18
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 {
|
|
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
|
+
'&': '&',
|
|
44
|
+
'<': '<',
|
|
45
|
+
'>': '>',
|
|
46
|
+
'"': '"',
|
|
47
|
+
"'": ''',
|
|
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.
|
|
212
|
-
|
|
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,
|
|
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${
|
|
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
|
-
|
|
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
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
320
|
-
|
|
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>${
|
|
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"
|
|
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>${
|
|
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">${
|
|
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>${
|
|
439
|
-
<div class="info-row"><span class="info-label">One-Time Share</span><span class="info-value"><code>${
|
|
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
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
'"': '"',
|
|
473
|
-
"'": ''',
|
|
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
|
|
653
|
+
appendEmptyState(container, 'No approval requests yet');
|
|
504
654
|
return;
|
|
505
655
|
}
|
|
506
656
|
|
|
507
|
-
|
|
657
|
+
const fragment = document.createDocumentFragment();
|
|
508
658
|
for (const req of pending) {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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.
|
|
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
|
|
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', [
|
|
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
|
-
|
|
826
|
+
const maxBrokerOutputBytes = 64 * 1024;
|
|
827
|
+
let brokerOutput = Buffer.alloc(0);
|
|
631
828
|
brokerProcess.all?.on('data', chunk => {
|
|
632
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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 =
|
|
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('
|
|
1131
|
+
const spinner = createSpinner('Revoking session and closing its tunnel...', networkSpinner).start();
|
|
931
1132
|
try {
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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('
|
|
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,
|
|
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
|
});
|