@miraj181/ipingyou 2.1.15 → 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 -16
- package/src/cli.js +33 -3
- package/src/lib/ai/safety.js +69 -12
- package/src/lib/chat.js +13 -5
- package/src/lib/cleanup.js +60 -93
- package/src/lib/open-url.js +28 -0
- package/src/lib/platform.js +38 -481
- package/src/lib/socket-firewall.js +34 -0
- package/src/lib/ssh.js +7 -1
- package/src/lib/uid.js +6 -3
- package/src/modes/ai.js +102 -26
- package/src/modes/client.js +3 -3
- package/src/modes/host.js +143 -96
package/src/modes/ai.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { execa } from 'execa';
|
|
6
|
-
import { parse as shellParse } from 'shell-quote';
|
|
7
6
|
import chalk from 'chalk';
|
|
8
7
|
import inquirer from 'inquirer';
|
|
9
8
|
import fs from 'node:fs';
|
|
@@ -11,13 +10,13 @@ import os from 'node:os';
|
|
|
11
10
|
import path from 'node:path';
|
|
12
11
|
import { getAlias } from '../lib/config.js';
|
|
13
12
|
import { resolveUID } from '../lib/broker.js';
|
|
14
|
-
import { buildSshArgs, extractHostname } from '../lib/ssh.js';
|
|
13
|
+
import { buildSshArgs, extractHostname, quoteRemoteShell } from '../lib/ssh.js';
|
|
15
14
|
import { addCleanupHook, cleanupAll } from '../lib/cleanup.js';
|
|
16
15
|
import { startHostMode } from './host.js';
|
|
17
16
|
import { startClientMode } from './client.js';
|
|
18
17
|
import { performSCPNonInteractive } from './client.js';
|
|
19
18
|
import { DEFAULT_AI_MODEL, createGroqChatCompletion, getGroqApiKey, getRateLimitWarnings, listGroqModels, estimateTokensForMessages } from '../lib/ai/groq.js';
|
|
20
|
-
import { classifyCommand, redactSensitive, sanitizeUserTask, truncateForModel } from '../lib/ai/safety.js';
|
|
19
|
+
import { assertSafeReadablePath, classifyCommand, redactSensitive, sanitizeUserTask, truncateForModel } from '../lib/ai/safety.js';
|
|
21
20
|
import { recordEvent } from '../lib/session-log.js';
|
|
22
21
|
|
|
23
22
|
let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
|
|
@@ -139,13 +138,8 @@ async function confirmCommand(scope, command, reason, classification) {
|
|
|
139
138
|
return false;
|
|
140
139
|
}
|
|
141
140
|
|
|
142
|
-
if (!classification.needsApproval) {
|
|
143
|
-
console.log(chalk.dim(` AI tool: ${scope} $ ${command}`));
|
|
144
|
-
return true;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
141
|
console.log('');
|
|
148
|
-
console.log(chalk.yellow(' AI wants to run a command
|
|
142
|
+
console.log(chalk.yellow(' AI wants to run a command:'));
|
|
149
143
|
console.log(chalk.dim(` Scope: ${scope}`));
|
|
150
144
|
console.log(chalk.dim(` Reason: ${reason || classification.reason}`));
|
|
151
145
|
console.log(chalk.cyan(` ${command}`));
|
|
@@ -160,13 +154,27 @@ async function confirmCommand(scope, command, reason, classification) {
|
|
|
160
154
|
return allow;
|
|
161
155
|
}
|
|
162
156
|
|
|
157
|
+
async function confirmFileRead(scope, filePath) {
|
|
158
|
+
console.log('');
|
|
159
|
+
console.log(chalk.yellow(' AI wants to read a text file and send redacted content to Groq:'));
|
|
160
|
+
console.log(chalk.dim(` Scope: ${scope}`));
|
|
161
|
+
console.log(chalk.cyan(` ${filePath}`));
|
|
162
|
+
const { allow } = await inquirer.prompt([{
|
|
163
|
+
type: 'confirm',
|
|
164
|
+
name: 'allow',
|
|
165
|
+
message: 'Allow this file read?',
|
|
166
|
+
default: false,
|
|
167
|
+
}]);
|
|
168
|
+
return allow;
|
|
169
|
+
}
|
|
170
|
+
|
|
163
171
|
function matchAppAction(task) {
|
|
164
172
|
const lowered = String(task || '').toLowerCase();
|
|
165
173
|
if (/\b(panic|self[- ]?destruct|wipe traces)\b/i.test(lowered)) {
|
|
166
174
|
return {
|
|
167
175
|
id: 'panic_blocked',
|
|
168
|
-
label: '
|
|
169
|
-
description: '
|
|
176
|
+
label: 'Emergency shutdown',
|
|
177
|
+
description: 'Emergency shutdown is never launched from AI mode. Run `ipingyou panic` directly and confirm it locally.',
|
|
170
178
|
blocked: true,
|
|
171
179
|
};
|
|
172
180
|
}
|
|
@@ -211,9 +219,7 @@ function showRateLimitWarnings(rateLimit) {
|
|
|
211
219
|
}
|
|
212
220
|
|
|
213
221
|
async function runLocalCommand(command) {
|
|
214
|
-
const
|
|
215
|
-
// Filter out non-string tokens (shell operators like |, &&, ; etc.) to prevent injection
|
|
216
|
-
const args = parsed.filter(token => typeof token === 'string');
|
|
222
|
+
const args = parseLocalCommand(command);
|
|
217
223
|
if (args.length === 0) {
|
|
218
224
|
return { exitCode: 1, stdout: '', stderr: 'Empty or unsafe command after parsing' };
|
|
219
225
|
}
|
|
@@ -230,6 +236,57 @@ async function runLocalCommand(command) {
|
|
|
230
236
|
};
|
|
231
237
|
}
|
|
232
238
|
|
|
239
|
+
export function parseLocalCommand(command) {
|
|
240
|
+
const input = String(command || '');
|
|
241
|
+
if (!input || input.length > 8192 || /[\u0000\r\n]/.test(input)) {
|
|
242
|
+
throw new Error('Command is empty, too long, or contains control characters');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const args = [];
|
|
246
|
+
let current = '';
|
|
247
|
+
let quote = null;
|
|
248
|
+
let tokenStarted = false;
|
|
249
|
+
|
|
250
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
251
|
+
const char = input[index];
|
|
252
|
+
if (quote) {
|
|
253
|
+
if (char === quote) {
|
|
254
|
+
quote = null;
|
|
255
|
+
} else if (char === '\\' && quote === '"' && index + 1 < input.length) {
|
|
256
|
+
current += input[++index];
|
|
257
|
+
} else {
|
|
258
|
+
current += char;
|
|
259
|
+
}
|
|
260
|
+
tokenStarted = true;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (char === "'" || char === '"') {
|
|
265
|
+
quote = char;
|
|
266
|
+
tokenStarted = true;
|
|
267
|
+
} else if (/\s/.test(char)) {
|
|
268
|
+
if (tokenStarted) {
|
|
269
|
+
args.push(current);
|
|
270
|
+
current = '';
|
|
271
|
+
tokenStarted = false;
|
|
272
|
+
}
|
|
273
|
+
} else if (char === '\\') {
|
|
274
|
+
if (index + 1 >= input.length) throw new Error('Command ends with an incomplete escape');
|
|
275
|
+
current += input[++index];
|
|
276
|
+
tokenStarted = true;
|
|
277
|
+
} else if (/[;&|`$()<>]/.test(char)) {
|
|
278
|
+
throw new Error('Local AI commands do not support shell operators');
|
|
279
|
+
} else {
|
|
280
|
+
current += char;
|
|
281
|
+
tokenStarted = true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (quote) throw new Error('Command contains an unterminated quote');
|
|
286
|
+
if (tokenStarted) args.push(current);
|
|
287
|
+
return args;
|
|
288
|
+
}
|
|
289
|
+
|
|
233
290
|
async function runRemoteCommand(context, command) {
|
|
234
291
|
const sshArgs = buildSshArgs(context.hostname, context.privateKeyPath);
|
|
235
292
|
sshArgs.push(`${context.username}@${context.hostname}`, command);
|
|
@@ -246,24 +303,34 @@ async function runRemoteCommand(context, command) {
|
|
|
246
303
|
};
|
|
247
304
|
}
|
|
248
305
|
|
|
249
|
-
function assertReadablePath(filePath) {
|
|
250
|
-
const classification = classifyCommand(`cat ${filePath}`);
|
|
251
|
-
if (classification.blocked) {
|
|
252
|
-
throw new Error(classification.reason);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
306
|
async function readLocalTextFile(filePath) {
|
|
257
|
-
|
|
258
|
-
const
|
|
307
|
+
const requestedPath = assertSafeReadablePath(filePath);
|
|
308
|
+
const expandedPath = requestedPath === '~'
|
|
309
|
+
? os.homedir()
|
|
310
|
+
: requestedPath.replace(/^~(?=[/\\])/, os.homedir());
|
|
311
|
+
const resolvedPath = fs.realpathSync(path.resolve(expandedPath));
|
|
312
|
+
assertSafeReadablePath(resolvedPath);
|
|
313
|
+
const stat = fs.statSync(resolvedPath);
|
|
259
314
|
if (!stat.isFile()) throw new Error('Path is not a file');
|
|
260
315
|
if (stat.size > 256 * 1024) throw new Error('File is too large for AI mode; use a targeted command instead');
|
|
261
|
-
return redactSensitive(fs.readFileSync(
|
|
316
|
+
return redactSensitive(fs.readFileSync(resolvedPath, 'utf8'));
|
|
262
317
|
}
|
|
263
318
|
|
|
264
319
|
async function readRemoteTextFile(context, filePath) {
|
|
265
|
-
|
|
266
|
-
|
|
320
|
+
const safePath = assertSafeReadablePath(filePath);
|
|
321
|
+
const script = [
|
|
322
|
+
'import pathlib, sys',
|
|
323
|
+
'p = pathlib.Path(sys.argv[1]).expanduser().resolve()',
|
|
324
|
+
'parts = {part.lower() for part in p.parts}',
|
|
325
|
+
"blocked = {'.ssh','.gnupg','.aws','.ipingyou','.kube','.docker'}",
|
|
326
|
+
"name = p.name.lower()",
|
|
327
|
+
"if parts & blocked or name in {'.npmrc','.netrc','.pypirc','credentials','credentials.json','known_hosts','authorized_keys','shadow','sudoers'} or name.startswith('.env') or name in {'id_rsa','id_dsa','id_ecdsa','id_ed25519'}: raise SystemExit('Protected path')",
|
|
328
|
+
"if not p.is_file(): raise SystemExit('Path is not a file')",
|
|
329
|
+
"if p.stat().st_size > 262144: raise SystemExit('File is too large for AI mode')",
|
|
330
|
+
"sys.stdout.write(p.read_text(errors='replace'))",
|
|
331
|
+
].join('\n');
|
|
332
|
+
const command = `python3 -c ${quoteRemoteShell(script)} -- ${quoteRemoteShell(safePath)}`;
|
|
333
|
+
return runRemoteCommand(context, command);
|
|
267
334
|
}
|
|
268
335
|
|
|
269
336
|
async function setupRemoteContext() {
|
|
@@ -391,6 +458,15 @@ async function executeToolCall(context, call) {
|
|
|
391
458
|
if (name === 'read_text_file') {
|
|
392
459
|
const filePath = String(args.filePath || '').trim();
|
|
393
460
|
if (!filePath) return buildToolResult({ ok: false, error: 'Missing filePath' });
|
|
461
|
+
try {
|
|
462
|
+
assertSafeReadablePath(filePath);
|
|
463
|
+
} catch (err) {
|
|
464
|
+
return buildToolResult({ ok: false, blocked: true, error: err.message });
|
|
465
|
+
}
|
|
466
|
+
if (!await confirmFileRead(context.scope, filePath)) {
|
|
467
|
+
return buildToolResult({ ok: false, blocked: true, error: 'User denied file access' });
|
|
468
|
+
}
|
|
469
|
+
recordEvent('ai_file_read_approved', { scope: context.scope });
|
|
394
470
|
|
|
395
471
|
if (context.scope === 'remote') {
|
|
396
472
|
const result = await readRemoteTextFile(context, filePath);
|
package/src/modes/client.js
CHANGED
|
@@ -26,7 +26,7 @@ import { calculateChecksum } from '../lib/checksum.js';
|
|
|
26
26
|
import { promptLocalPath, promptRemotePath } from '../lib/path-browser.js';
|
|
27
27
|
import { buildProxyCommandOption, buildSshArgs, extractHostname, formatScpRemotePath, getKnownHostsOptions, getSshControlOptions, quoteRemoteShell } from '../lib/ssh.js';
|
|
28
28
|
import { buildTmuxSessionName, TMUX_SOCKET_PATH } from '../lib/tmux.js';
|
|
29
|
-
import
|
|
29
|
+
import { openUrl } from '../lib/open-url.js';
|
|
30
30
|
import { secureSensitiveUrl } from '../lib/secure-print.js';
|
|
31
31
|
import { cleanupSessionLog, initSessionLog, logSessionEvent, recordEvent } from '../lib/session-log.js';
|
|
32
32
|
|
|
@@ -479,7 +479,7 @@ export async function startClientMode(options = {}) {
|
|
|
479
479
|
console.log('');
|
|
480
480
|
logSessionEvent('client_http_mode', { uid: targetUid, port: payload.port || null });
|
|
481
481
|
try {
|
|
482
|
-
await
|
|
482
|
+
await openUrl(payload.url);
|
|
483
483
|
} catch {
|
|
484
484
|
console.log(chalk.dim(` Could not auto-open. Please visit: ${payload.url}`));
|
|
485
485
|
}
|
|
@@ -711,7 +711,7 @@ async function handleClientChat(uid, password, cachedChatUrl) {
|
|
|
711
711
|
spinner.succeed('Chat Room found! Opening browser...');
|
|
712
712
|
try {
|
|
713
713
|
const fullUrl = `${chatUrl}#${password}`;
|
|
714
|
-
await
|
|
714
|
+
await openUrl(fullUrl);
|
|
715
715
|
} catch {
|
|
716
716
|
console.log(chalk.cyan(` 👉 Please open: ${secureSensitiveUrl(chatUrl, password)}`));
|
|
717
717
|
}
|
package/src/modes/host.js
CHANGED
|
@@ -21,7 +21,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
21
21
|
import { createRequire } from 'node:module';
|
|
22
22
|
import fs from 'node:fs';
|
|
23
23
|
import os from 'node:os';
|
|
24
|
+
import crypto from 'node:crypto';
|
|
24
25
|
import { generateUID } from '../lib/uid.js';
|
|
26
|
+
import { openUrl } from '../lib/open-url.js';
|
|
25
27
|
import { decryptAsync } from '../lib/crypto.js';
|
|
26
28
|
import { cleanupAll, killProcessTree, trackPID, untrackPID, setRevokeOnExit, addCleanupHook } from '../lib/cleanup.js';
|
|
27
29
|
import { detectOS } from '../lib/platform.js';
|
|
@@ -36,6 +38,16 @@ import { TMUX_SESSION_NAME, TMUX_SESSION_PREFIX, tmuxSocketArgs } from '../lib/t
|
|
|
36
38
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
37
39
|
let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
|
|
38
40
|
|
|
41
|
+
function escapeDashboardHtml(value) {
|
|
42
|
+
return String(value ?? '').replace(/[&<>"']/g, character => ({
|
|
43
|
+
'&': '&',
|
|
44
|
+
'<': '<',
|
|
45
|
+
'>': '>',
|
|
46
|
+
'"': '"',
|
|
47
|
+
"'": ''',
|
|
48
|
+
})[character]);
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
async function waitForValue(getValue, timeoutMs, label) {
|
|
40
52
|
const startedAt = Date.now();
|
|
41
53
|
while (!getValue()) {
|
|
@@ -209,15 +221,26 @@ async function injectPublicKey(pubKey) {
|
|
|
209
221
|
}
|
|
210
222
|
|
|
211
223
|
const authKeysPath = path.join(sshDir, 'authorized_keys');
|
|
212
|
-
await fs.promises.
|
|
213
|
-
|
|
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 };
|
|
214
232
|
}
|
|
215
233
|
|
|
216
|
-
async function removePublicKey(authKeysPath,
|
|
234
|
+
async function removePublicKey(authKeysPath, authorizedKey) {
|
|
217
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
|
+
}
|
|
218
240
|
let keys = await fs.promises.readFile(authKeysPath, 'utf8');
|
|
219
|
-
keys = keys.replace(`\n${
|
|
241
|
+
keys = keys.replace(`\n${authorizedKey}\n`, '');
|
|
220
242
|
await fs.promises.writeFile(authKeysPath, keys);
|
|
243
|
+
await fs.promises.chmod(authKeysPath, 0o600);
|
|
221
244
|
}
|
|
222
245
|
}
|
|
223
246
|
|
|
@@ -275,13 +298,17 @@ async function promptOneTimeSharePath() {
|
|
|
275
298
|
|
|
276
299
|
async function startLocalHostDashboard(uid, password, serviceConfig, sessionState) {
|
|
277
300
|
const { default: express } = await import('express');
|
|
278
|
-
const { default: open } = await import('open');
|
|
279
301
|
const app = express();
|
|
280
302
|
const startedAt = new Date().toISOString();
|
|
281
303
|
const decryptedClientCache = new Map();
|
|
282
304
|
const MAX_DECRYPTED_CLIENT_CACHE = 100;
|
|
283
305
|
const activeEventStreams = new Set();
|
|
284
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');
|
|
285
312
|
|
|
286
313
|
async function fetchDecryptedClients() {
|
|
287
314
|
const brokerRes = await fetch(`${BROKER_URL}/clients/${uid}`, {
|
|
@@ -464,6 +491,17 @@ async function startLocalHostDashboard(uid, password, serviceConfig, sessionStat
|
|
|
464
491
|
});
|
|
465
492
|
|
|
466
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('; '));
|
|
467
505
|
res.type('html').send(`<!doctype html>
|
|
468
506
|
<html lang="en"><head><meta charset="utf-8"><title>iPingYou Host Dashboard</title>
|
|
469
507
|
<style>
|
|
@@ -504,22 +542,22 @@ code{background:var(--bg);padding:2px 8px;border-radius:4px;font-size:0.85rem}
|
|
|
504
542
|
</style></head>
|
|
505
543
|
<body>
|
|
506
544
|
<div class="header">
|
|
507
|
-
<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>
|
|
508
546
|
<div style="display:flex;gap:1rem;align-items:center">
|
|
509
547
|
<span class="badge">● LIVE</span>
|
|
510
|
-
<button class="btn btn-revoke"
|
|
548
|
+
<button id="revoke-session" class="btn btn-revoke" type="button">🚫 Revoke Session</button>
|
|
511
549
|
</div>
|
|
512
550
|
</div>
|
|
513
551
|
|
|
514
552
|
<div class="grid">
|
|
515
553
|
<div class="card">
|
|
516
554
|
<h2>📊 Session Info</h2>
|
|
517
|
-
<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>
|
|
518
556
|
<div class="info-row"><span class="info-label">Password</span><span class="info-value"><code>[Hidden — see terminal]</code></span></div>
|
|
519
|
-
<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>
|
|
520
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>
|
|
521
|
-
<div class="info-row"><span class="info-label">Drop Folder</span><span class="info-value"><code>${
|
|
522
|
-
<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>
|
|
523
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>
|
|
524
562
|
<div class="info-row"><span class="info-label">Uptime</span><span class="info-value" id="uptime">—</span></div>
|
|
525
563
|
</div>
|
|
@@ -537,7 +575,7 @@ code{background:var(--bg);padding:2px 8px;border-radius:4px;font-size:0.85rem}
|
|
|
537
575
|
|
|
538
576
|
<div id="toast"></div>
|
|
539
577
|
|
|
540
|
-
<script>
|
|
578
|
+
<script nonce="${scriptNonce}">
|
|
541
579
|
const startedAt = new Date("${startedAt}");
|
|
542
580
|
|
|
543
581
|
function showToast(msg) {
|
|
@@ -547,14 +585,11 @@ function showToast(msg) {
|
|
|
547
585
|
setTimeout(() => t.classList.remove('show'), 2500);
|
|
548
586
|
}
|
|
549
587
|
|
|
550
|
-
function
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
'"': '"',
|
|
556
|
-
"'": ''',
|
|
557
|
-
})[m]);
|
|
588
|
+
function appendEmptyState(container, text) {
|
|
589
|
+
const message = document.createElement('p');
|
|
590
|
+
message.className = 'empty';
|
|
591
|
+
message.textContent = text;
|
|
592
|
+
container.replaceChildren(message);
|
|
558
593
|
}
|
|
559
594
|
|
|
560
595
|
function updateUptime() {
|
|
@@ -615,39 +650,63 @@ function renderApprovals(approvals) {
|
|
|
615
650
|
document.getElementById('approval-count').textContent = pending.length > 0 ? '(' + pending.length + ' pending)' : '';
|
|
616
651
|
|
|
617
652
|
if (approvals.length === 0) {
|
|
618
|
-
container
|
|
653
|
+
appendEmptyState(container, 'No approval requests yet');
|
|
619
654
|
return;
|
|
620
655
|
}
|
|
621
656
|
|
|
622
|
-
|
|
657
|
+
const fragment = document.createDocumentFragment();
|
|
623
658
|
for (const req of pending) {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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);
|
|
633
689
|
}
|
|
634
690
|
for (const req of decided) {
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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);
|
|
642
708
|
}
|
|
643
|
-
container.
|
|
644
|
-
|
|
645
|
-
// Attach click handlers via event delegation (avoids inline quote escaping issues)
|
|
646
|
-
container.querySelectorAll('[data-decision]').forEach(function(btn) {
|
|
647
|
-
btn.addEventListener('click', function() {
|
|
648
|
-
decide(btn.getAttribute('data-id'), btn.getAttribute('data-decision'));
|
|
649
|
-
});
|
|
650
|
-
});
|
|
709
|
+
container.replaceChildren(fragment);
|
|
651
710
|
}
|
|
652
711
|
|
|
653
712
|
async function decide(requestId, decision) {
|
|
@@ -670,34 +729,48 @@ async function revokeSession() {
|
|
|
670
729
|
else showToast('Failed to revoke');
|
|
671
730
|
} catch (err) { showToast('Error: ' + err.message); }
|
|
672
731
|
}
|
|
732
|
+
document.getElementById('revoke-session').addEventListener('click', revokeSession);
|
|
673
733
|
|
|
674
734
|
function renderClients(clients) {
|
|
675
735
|
const container = document.getElementById('clients');
|
|
676
736
|
if (!clients || clients.length === 0) {
|
|
677
|
-
container
|
|
737
|
+
appendEmptyState(container, 'No clients connected yet');
|
|
678
738
|
document.getElementById('client-count').textContent = '';
|
|
679
739
|
return;
|
|
680
740
|
}
|
|
681
741
|
|
|
682
742
|
document.getElementById('client-count').textContent = '(' + clients.length + ' connected)';
|
|
683
|
-
|
|
743
|
+
const fragment = document.createDocumentFragment();
|
|
684
744
|
clients.forEach((c, idx) => {
|
|
745
|
+
const card = document.createElement('div');
|
|
746
|
+
card.className = 'client-card';
|
|
685
747
|
if (c.error) {
|
|
686
|
-
|
|
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);
|
|
687
752
|
return;
|
|
688
753
|
}
|
|
689
754
|
const when = c.time || (c.seenAt ? new Date(c.seenAt).toLocaleTimeString() : 'Unknown');
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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);
|
|
699
772
|
});
|
|
700
|
-
container.
|
|
773
|
+
container.replaceChildren(fragment);
|
|
701
774
|
}
|
|
702
775
|
|
|
703
776
|
</script>
|
|
@@ -709,7 +782,7 @@ function renderClients(clients) {
|
|
|
709
782
|
const port = server.address().port;
|
|
710
783
|
const url = `http://127.0.0.1:${port}`;
|
|
711
784
|
console.log(chalk.green(` ✓ Local dashboard: ${url}`));
|
|
712
|
-
try { await
|
|
785
|
+
try { await openUrl(url); } catch { }
|
|
713
786
|
resolve({ url, close: () => server.close() });
|
|
714
787
|
});
|
|
715
788
|
});
|
|
@@ -721,7 +794,6 @@ function renderClients(clients) {
|
|
|
721
794
|
async function spawnPrivateBroker() {
|
|
722
795
|
console.log(chalk.yellow('\n ⚠️ Public Broker is unreachable. Spawning Private Broker...'));
|
|
723
796
|
|
|
724
|
-
const packageRoot = path.resolve(__dirname, '../..');
|
|
725
797
|
const serverEntrypoint = path.join(__dirname, '../server.js');
|
|
726
798
|
const requireFromServer = createRequire(serverEntrypoint);
|
|
727
799
|
const requiredBrokerPackages = ['express', 'express-rate-limit', 'helmet'];
|
|
@@ -735,34 +807,10 @@ async function spawnPrivateBroker() {
|
|
|
735
807
|
});
|
|
736
808
|
|
|
737
809
|
if (missingPackages.length > 0) {
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
npmCmd,
|
|
742
|
-
['install', '--no-save', '--no-audit', '--no-fund', ...missingPackages],
|
|
743
|
-
{ cwd: packageRoot, reject: false, all: true }
|
|
810
|
+
throw new Error(
|
|
811
|
+
`Private broker dependencies are missing: ${missingPackages.join(', ')}. `
|
|
812
|
+
+ 'Reinstall iPingYou from its verified package before starting a private broker.'
|
|
744
813
|
);
|
|
745
|
-
|
|
746
|
-
if (installResult.exitCode !== 0) {
|
|
747
|
-
const installOutput = installResult.all?.trim();
|
|
748
|
-
throw new Error(
|
|
749
|
-
`Private broker dependencies failed to install (${missingPackages.join(', ')})`
|
|
750
|
-
+ (installOutput ? `: ${installOutput}` : '')
|
|
751
|
-
);
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
// Verify modules are now resolvable for the server entrypoint context.
|
|
755
|
-
const unresolved = missingPackages.filter((pkg) => {
|
|
756
|
-
try {
|
|
757
|
-
requireFromServer.resolve(pkg);
|
|
758
|
-
return false;
|
|
759
|
-
} catch {
|
|
760
|
-
return true;
|
|
761
|
-
}
|
|
762
|
-
});
|
|
763
|
-
if (unresolved.length > 0) {
|
|
764
|
-
throw new Error(`Private broker dependencies are still missing after install: ${unresolved.join(', ')}`);
|
|
765
|
-
}
|
|
766
814
|
}
|
|
767
815
|
|
|
768
816
|
// 1. Spawn the broker server process
|
|
@@ -1080,12 +1128,11 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
|
|
|
1080
1128
|
return waitForAction();
|
|
1081
1129
|
|
|
1082
1130
|
case 'terminate': {
|
|
1083
|
-
const spinner = createSpinner('
|
|
1131
|
+
const spinner = createSpinner('Revoking session and closing its tunnel...', networkSpinner).start();
|
|
1084
1132
|
try {
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
await execa('pkill', ['-f', 'sshd:.*@'], { reject: false });
|
|
1133
|
+
await revokeUID(BROKER_URL, uid, sessionState.hostToken);
|
|
1134
|
+
tunnelProcess.kill();
|
|
1135
|
+
if (process.platform !== 'win32') {
|
|
1089
1136
|
await execa('tmux', [...tmuxSocketArgs(), 'kill-server'], { reject: false });
|
|
1090
1137
|
const legacySessions = await listTmuxSessions();
|
|
1091
1138
|
for (const session of legacySessions) {
|
|
@@ -1094,7 +1141,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
|
|
|
1094
1141
|
}
|
|
1095
1142
|
}
|
|
1096
1143
|
}
|
|
1097
|
-
spinner.succeed('
|
|
1144
|
+
spinner.succeed('Session revoked and iPingYou-owned connections terminated');
|
|
1098
1145
|
logSessionEvent('host_sessions_terminated');
|
|
1099
1146
|
} catch {
|
|
1100
1147
|
spinner.warn('Could not terminate sessions (none active?)');
|
|
@@ -1271,13 +1318,13 @@ export async function startHostMode() {
|
|
|
1271
1318
|
console.log(chalk.dim(' 🔑 Generating ephemeral SSH key for passwordless entry...'));
|
|
1272
1319
|
try {
|
|
1273
1320
|
const ephemeralKey = await generateEphemeralKey();
|
|
1274
|
-
const authKeysPath = await injectPublicKey(ephemeralKey.pubKey);
|
|
1321
|
+
const { authKeysPath, authorizedKey } = await injectPublicKey(ephemeralKey.pubKey);
|
|
1275
1322
|
|
|
1276
1323
|
serviceConfig.privateKey = ephemeralKey.privKey;
|
|
1277
1324
|
|
|
1278
1325
|
addCleanupHook(async () => {
|
|
1279
1326
|
console.log(chalk.dim(' Removing ephemeral public key...'));
|
|
1280
|
-
await removePublicKey(authKeysPath,
|
|
1327
|
+
await removePublicKey(authKeysPath, authorizedKey);
|
|
1281
1328
|
try { await fs.promises.unlink(ephemeralKey.keyPath); } catch { }
|
|
1282
1329
|
try { await fs.promises.unlink(`${ephemeralKey.keyPath}.pub`); } catch { }
|
|
1283
1330
|
});
|