@miraj181/ipingyou 2.1.9 → 2.1.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraj181/ipingyou",
3
- "version": "2.1.9",
3
+ "version": "2.1.15",
4
4
  "description": "SecureLink-CLI — Secure peer-to-peer remote access via SSH & Cloudflare Tunnels",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
@@ -46,7 +46,10 @@
46
46
  "dependencies": {
47
47
  "chalk": "^5.3.0",
48
48
  "commander": "^15.0.0",
49
+ "express": "^4.21.2",
50
+ "express-rate-limit": "^7.5.0",
49
51
  "execa": "^9.5.2",
52
+ "helmet": "^8.0.0",
50
53
  "inquirer": "^12.11.1",
51
54
  "nanoid": "^5.0.9",
52
55
  "open": "^11.0.0",
package/src/cli.js CHANGED
@@ -23,6 +23,7 @@ import inquirer from 'inquirer';
23
23
  import chalk from 'chalk';
24
24
  import fs from 'node:fs';
25
25
  import path from 'node:path';
26
+ import readline from 'node:readline';
26
27
  import { fileURLToPath } from 'node:url';
27
28
 
28
29
  import { detectOS, checkDependencies } from './lib/platform.js';
@@ -474,26 +475,28 @@ program
474
475
  return;
475
476
  }
476
477
 
477
- const raw = fs.readFileSync(logFile, 'utf8').trim();
478
- if (!raw) {
479
- console.log(chalk.dim(' Log file is empty.'));
480
- return;
481
- }
482
-
483
- let events = raw.split('\n').map(line => {
484
- try { return JSON.parse(line); } catch { return null; }
485
- }).filter(Boolean);
486
-
487
- // Filter by type if specified
488
- if (commandOptions.type) {
489
- const filter = commandOptions.type.toLowerCase();
490
- events = events.filter(e => (e.type || '').toLowerCase().includes(filter));
478
+ const parsedCount = Number.parseInt(commandOptions.lines, 10);
479
+ const count = Number.isFinite(parsedCount) ? Math.max(1, Math.min(parsedCount, 10000)) : 25;
480
+ const filter = commandOptions.type?.toLowerCase();
481
+ const events = [];
482
+ let totalEvents = 0;
483
+ const lines = readline.createInterface({
484
+ input: fs.createReadStream(logFile, { encoding: 'utf8' }),
485
+ crlfDelay: Infinity,
486
+ });
487
+ for await (const line of lines) {
488
+ if (!line.trim()) continue;
489
+ totalEvents += 1;
490
+ try {
491
+ const event = JSON.parse(line);
492
+ if (filter && !(event.type || '').toLowerCase().includes(filter)) continue;
493
+ events.push(event);
494
+ if (events.length > count) events.shift();
495
+ } catch {
496
+ // Ignore incomplete or invalid log lines.
497
+ }
491
498
  }
492
499
 
493
- // Take last N events
494
- const count = parseInt(commandOptions.lines) || 25;
495
- events = events.slice(-count);
496
-
497
500
  if (events.length === 0) {
498
501
  console.log(chalk.dim(' No matching events found.'));
499
502
  return;
@@ -526,7 +529,7 @@ program
526
529
 
527
530
  console.log('');
528
531
  console.log(chalk.dim(` Log file: ${logFile}`));
529
- console.log(chalk.dim(` Total events in file: ${raw.split('\n').length}`));
532
+ console.log(chalk.dim(` Total events in file: ${totalEvents}`));
530
533
  } catch (err) {
531
534
  fatal('history', err);
532
535
  }
package/src/lib/broker.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import os from 'node:os';
3
3
  import crypto from 'node:crypto';
4
- import { decrypt, encrypt } from './crypto.js';
4
+ import { decryptAsync, encryptAsync } from './crypto.js';
5
5
  import { createSpinner, cryptoSpinner, networkSpinner } from './animations.js';
6
6
  import { logSessionEvent } from './session-log.js';
7
7
 
@@ -51,7 +51,7 @@ export async function registerWithBroker(brokerUrl, uid, tunnelUrl, password, se
51
51
  try {
52
52
  await new Promise(r => setTimeout(r, 600));
53
53
  const payload = JSON.stringify({ url: tunnelUrl, ...serviceConfig });
54
- const encrypted = encrypt(payload, password);
54
+ const encrypted = await encryptAsync(payload, password);
55
55
  const localHostToken = crypto.randomBytes(32).toString('hex');
56
56
 
57
57
  spinner.text = 'Registering with broker...';
@@ -92,7 +92,7 @@ export async function registerWithBroker(brokerUrl, uid, tunnelUrl, password, se
92
92
  }
93
93
 
94
94
  export async function requestHostApproval(brokerUrl, uid, password, details) {
95
- const encrypted = encrypt(JSON.stringify(details), password);
95
+ const encrypted = await encryptAsync(JSON.stringify(details), password);
96
96
  const res = await fetchWithLog('approval_request', `${brokerUrl}/approval-request/${uid}`, {
97
97
  method: 'POST',
98
98
  headers: { 'Content-Type': 'application/json' },
@@ -209,7 +209,7 @@ export async function resolveUID(brokerUrl, uid, password, silent = false, reque
209
209
 
210
210
  let decryptedPayload;
211
211
  try {
212
- decryptedPayload = decrypt(data.iv, data.ciphertext, password, data.salt);
212
+ decryptedPayload = await decryptAsync(data.iv, data.ciphertext, password, data.salt);
213
213
  } catch {
214
214
  if (spinner) spinner.fail('Decryption failed — incorrect password or corrupted data');
215
215
  if (!spinner) console.error(chalk.red(' ❌ Error: Could not decrypt tunnel data. Incorrect password.'));
@@ -269,7 +269,7 @@ export async function pushTelemetry(brokerUrl, uid, password, username, action =
269
269
  time: new Date().toISOString()
270
270
  };
271
271
 
272
- const { iv, ciphertext, salt } = encrypt(JSON.stringify(telemetry), password);
272
+ const { iv, ciphertext, salt } = await encryptAsync(JSON.stringify(telemetry), password);
273
273
 
274
274
  await fetchWithLog('telemetry', `${parsed.origin}/client-info/${encodeURIComponent(uid)}`, {
275
275
  method: 'POST',
package/src/lib/chat.js CHANGED
@@ -168,7 +168,13 @@ const HTML_CONTENT = `
168
168
  div.textContent = msg.text;
169
169
  } else {
170
170
  div.className = 'message ' + (msg.sender === username ? 'self' : 'other');
171
- div.innerHTML = '<div class="message-header">' + msg.sender + ' • ' + msg.time + '</div><div>' + msg.text + '</div>';
171
+ const header = document.createElement('div');
172
+ header.className = 'message-header';
173
+ header.textContent = String(msg.sender || 'Unknown') + ' • ' + String(msg.time || '');
174
+ const body = document.createElement('div');
175
+ body.textContent = String(msg.text || '');
176
+ div.appendChild(header);
177
+ div.appendChild(body);
172
178
  }
173
179
  msgs.appendChild(div);
174
180
  msgs.scrollTop = msgs.scrollHeight;
@@ -1,12 +1,32 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
+ import { canUseWorkers, runWorkerTask } from './worker-runtime.js';
3
4
 
4
- export async function calculateChecksum(filePath) {
5
+ const WORKER_CHECKSUM_THRESHOLD_BYTES = 2 * 1024 * 1024;
6
+
7
+ async function calculateChecksumStream(filePath) {
5
8
  return new Promise((resolve) => {
6
9
  const hash = crypto.createHash('sha256');
7
10
  const stream = fs.createReadStream(filePath);
8
11
  stream.on('error', () => resolve(null));
9
- stream.on('data', chunk => hash.update(chunk));
12
+ stream.on('data', (chunk) => hash.update(chunk));
10
13
  stream.on('end', () => resolve(hash.digest('hex')));
11
14
  });
12
15
  }
16
+
17
+ export async function calculateChecksum(filePath) {
18
+ const stat = await fs.promises.stat(filePath).catch(() => null);
19
+ if (!stat || !stat.isFile()) return null;
20
+
21
+ if (canUseWorkers() && stat.size >= WORKER_CHECKSUM_THRESHOLD_BYTES) {
22
+ try {
23
+ const result = await runWorkerTask('checksum', { filePath });
24
+ return result.digest || null;
25
+ } catch (err) {
26
+ if (err?.code === 'WORKER_QUEUE_FULL') throw err;
27
+ return calculateChecksumStream(filePath);
28
+ }
29
+ }
30
+
31
+ return calculateChecksumStream(filePath);
32
+ }
package/src/lib/crypto.js CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import crypto from 'node:crypto';
11
+ import { canUseWorkers, runWorkerTask } from './worker-runtime.js';
11
12
 
12
13
  /**
13
14
  * Derive a 256-bit encryption key from a password and salt using PBKDF2.
@@ -42,6 +43,21 @@ export function encrypt(plaintext, password) {
42
43
  };
43
44
  }
44
45
 
46
+ export async function encryptAsync(plaintext, password) {
47
+ if (!canUseWorkers()) return encrypt(plaintext, password);
48
+ try {
49
+ const result = await runWorkerTask('encrypt', { plaintext, password });
50
+ return {
51
+ iv: result.iv,
52
+ ciphertext: result.ciphertext,
53
+ salt: result.salt,
54
+ };
55
+ } catch (err) {
56
+ if (err?.code === 'WORKER_QUEUE_FULL') throw err;
57
+ return encrypt(plaintext, password);
58
+ }
59
+ }
60
+
45
61
  /**
46
62
  * Decrypt a ciphertext with AES-256-CBC using a password and salt.
47
63
  * @param {string} ivHex — 32-char hex IV
@@ -60,3 +76,14 @@ export function decrypt(ivHex, cipherBase64, password, saltHex) {
60
76
  dec += decipher.final('utf8');
61
77
  return dec;
62
78
  }
79
+
80
+ export async function decryptAsync(ivHex, cipherBase64, password, saltHex) {
81
+ if (!canUseWorkers()) return decrypt(ivHex, cipherBase64, password, saltHex);
82
+ try {
83
+ const result = await runWorkerTask('decrypt', { ivHex, cipherBase64, password, saltHex });
84
+ return result.plaintext;
85
+ } catch (err) {
86
+ if (err?.code === 'WORKER_QUEUE_FULL') throw err;
87
+ return decrypt(ivHex, cipherBase64, password, saltHex);
88
+ }
89
+ }
@@ -18,6 +18,8 @@ import os from 'node:os';
18
18
  // Keyed secret for deterministic, non-reversible log masking tokens.
19
19
  // Can be overridden for stable cross-process correlation if needed.
20
20
  const MASKING_KEY = process.env.SECURE_PRINT_MASK_KEY || crypto.randomBytes(32).toString('hex');
21
+ let lastMaskedFingerprint = null;
22
+ let lastMaskedResult = null;
21
23
 
22
24
  /**
23
25
  * Verify the current process is being run by the same OS user interactively.
@@ -47,10 +49,14 @@ function isVerifiedHostUser() {
47
49
  */
48
50
  function maskSensitive(value) {
49
51
  const normalized = String(value);
52
+ const fingerprint = crypto.createHash('sha256').update(normalized).digest('hex');
53
+ if (fingerprint === lastMaskedFingerprint) return lastMaskedResult;
50
54
  const hash = crypto
51
55
  .pbkdf2Sync(MASKING_KEY, normalized, 210000, 32, 'sha256')
52
56
  .toString('hex');
53
- return `[pbkdf2:${hash.slice(0, 12)}…]`;
57
+ lastMaskedFingerprint = fingerprint;
58
+ lastMaskedResult = `[pbkdf2:${hash.slice(0, 12)}…]`;
59
+ return lastMaskedResult;
54
60
  }
55
61
 
56
62
  /**
@@ -6,13 +6,47 @@ import { redactSensitive } from './ai/safety.js';
6
6
  const LOG_DIR = path.join(os.homedir(), '.ipingyou', 'logs');
7
7
  const LOG_FILE = path.join(LOG_DIR, 'session-events.jsonl');
8
8
  const SESSION_LOG_DIR = path.join(os.tmpdir(), 'ipingyou-session-logs');
9
+ const MAX_HISTORY_LOG_BYTES = 5 * 1024 * 1024;
10
+ const MAX_SESSION_LOG_BYTES = 2 * 1024 * 1024;
11
+ const MAX_LOG_STRING_LENGTH = 16 * 1024;
12
+ const SESSION_LOG_FLUSH_BYTES = 64 * 1024;
13
+ const SESSION_LOG_FLUSH_MS = 250;
9
14
 
10
15
  let sessionLogPath = null;
11
16
  let sessionLogDisabled = false;
12
17
  let cleanupRegistered = false;
18
+ let sessionLogBytes = 0;
19
+ let historyLogBytes = null;
20
+ let sessionLogBuffer = '';
21
+ let sessionLogFlushTimer = null;
22
+
23
+ function flushSessionLog() {
24
+ if (sessionLogFlushTimer) clearTimeout(sessionLogFlushTimer);
25
+ sessionLogFlushTimer = null;
26
+ if (!sessionLogPath || !sessionLogBuffer) return;
27
+ const buffered = sessionLogBuffer;
28
+ sessionLogBuffer = '';
29
+ try {
30
+ fs.appendFileSync(sessionLogPath, buffered, { mode: 0o600 });
31
+ } catch (err) {
32
+ sessionLogDisabled = true;
33
+ console.error(`Session log write failed: ${err.message}`);
34
+ }
35
+ }
36
+
37
+ function scheduleSessionLogFlush() {
38
+ if (sessionLogFlushTimer) return;
39
+ sessionLogFlushTimer = setTimeout(flushSessionLog, SESSION_LOG_FLUSH_MS);
40
+ sessionLogFlushTimer.unref?.();
41
+ }
13
42
 
14
43
  function sanitize(value) {
15
- if (typeof value === 'string') return redactSensitive(value);
44
+ if (typeof value === 'string') {
45
+ const redacted = redactSensitive(value);
46
+ return redacted.length > MAX_LOG_STRING_LENGTH
47
+ ? `${redacted.slice(0, MAX_LOG_STRING_LENGTH)}…[truncated]`
48
+ : redacted;
49
+ }
16
50
  if (Array.isArray(value)) return value.map(sanitize);
17
51
  if (value && typeof value === 'object') {
18
52
  return Object.fromEntries(
@@ -28,11 +62,23 @@ export function initSessionLog(scope = 'session') {
28
62
  if (sessionLogPath || sessionLogDisabled) return sessionLogPath;
29
63
  try {
30
64
  fs.mkdirSync(SESSION_LOG_DIR, { recursive: true, mode: 0o700 });
65
+ const staleBefore = Date.now() - (24 * 60 * 60 * 1000);
66
+ for (const name of fs.readdirSync(SESSION_LOG_DIR)) {
67
+ if (!name.startsWith('ipingyou-')) continue;
68
+ const candidate = path.join(SESSION_LOG_DIR, name);
69
+ try {
70
+ if (fs.statSync(candidate).mtimeMs < staleBefore) fs.unlinkSync(candidate);
71
+ } catch {
72
+ // Best-effort cleanup; another process may own or remove the file.
73
+ }
74
+ }
31
75
  sessionLogPath = path.join(
32
76
  SESSION_LOG_DIR,
33
77
  `ipingyou-${scope}-${Date.now()}-${process.pid}.log`
34
78
  );
35
79
  fs.writeFileSync(sessionLogPath, '', { mode: 0o600 });
80
+ sessionLogBytes = 0;
81
+ sessionLogBuffer = '';
36
82
  if (!cleanupRegistered) {
37
83
  process.on('exit', () => cleanupSessionLog());
38
84
  cleanupRegistered = true;
@@ -59,7 +105,19 @@ export function logSessionEvent(type, details = {}, level = 'info') {
59
105
  details: sanitize(details),
60
106
  };
61
107
  try {
62
- fs.appendFileSync(sessionLogPath, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
108
+ const line = `${JSON.stringify(entry)}\n`;
109
+ const lineBytes = Buffer.byteLength(line);
110
+ if (sessionLogBytes + lineBytes > MAX_SESSION_LOG_BYTES) {
111
+ sessionLogDisabled = true;
112
+ return;
113
+ }
114
+ sessionLogBuffer += line;
115
+ sessionLogBytes += lineBytes;
116
+ if (Buffer.byteLength(sessionLogBuffer) >= SESSION_LOG_FLUSH_BYTES) {
117
+ flushSessionLog();
118
+ } else {
119
+ scheduleSessionLogFlush();
120
+ }
63
121
  } catch (err) {
64
122
  sessionLogDisabled = true;
65
123
  console.error(`Session log write failed: ${err.message}`);
@@ -68,6 +126,7 @@ export function logSessionEvent(type, details = {}, level = 'info') {
68
126
 
69
127
  export function cleanupSessionLog() {
70
128
  if (!sessionLogPath) return;
129
+ flushSessionLog();
71
130
  const target = sessionLogPath;
72
131
  sessionLogPath = null;
73
132
  try {
@@ -85,7 +144,23 @@ export function recordEvent(type, details = {}) {
85
144
  type,
86
145
  details: sanitize(details),
87
146
  };
88
- fs.appendFileSync(LOG_FILE, `${JSON.stringify(event)}\n`, { mode: 0o600 });
147
+ const line = `${JSON.stringify(event)}\n`;
148
+ const lineBytes = Buffer.byteLength(line);
149
+ if (historyLogBytes === null) {
150
+ historyLogBytes = fs.existsSync(LOG_FILE) ? fs.statSync(LOG_FILE).size : 0;
151
+ }
152
+ if (historyLogBytes + lineBytes > MAX_HISTORY_LOG_BYTES) {
153
+ const previousLog = `${LOG_FILE}.1`;
154
+ try {
155
+ if (fs.existsSync(previousLog)) fs.unlinkSync(previousLog);
156
+ if (fs.existsSync(LOG_FILE)) fs.renameSync(LOG_FILE, previousLog);
157
+ } catch {
158
+ fs.writeFileSync(LOG_FILE, '', { mode: 0o600 });
159
+ }
160
+ historyLogBytes = 0;
161
+ }
162
+ fs.appendFileSync(LOG_FILE, line, { mode: 0o600 });
163
+ historyLogBytes += lineBytes;
89
164
  logSessionEvent(type, details);
90
165
  } catch {
91
166
  // Session recording is best-effort.
package/src/lib/ssh.js CHANGED
@@ -1,11 +1,26 @@
1
1
  import crypto from 'node:crypto';
2
2
 
3
+ const SAFE_HOSTNAME_PATTERN = /^(?=.{1,253}$)(?!.*\.\.)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
4
+
5
+ export function assertSafeHostname(hostname, label = 'hostname') {
6
+ const normalized = String(hostname || '').trim().replace(/\.$/, '').toLowerCase();
7
+ if (!normalized || !SAFE_HOSTNAME_PATTERN.test(normalized)) {
8
+ throw new Error(`Invalid ${label}`);
9
+ }
10
+ return normalized;
11
+ }
12
+
3
13
  export function extractHostname(url) {
14
+ let parsed;
4
15
  try {
5
- return new URL(url).hostname;
16
+ parsed = new URL(url);
6
17
  } catch {
7
- return url.replace(/^https?:\/\//, '').replace(/\/$/, '');
18
+ throw new Error('Invalid tunnel URL');
8
19
  }
20
+ if (parsed.protocol !== 'https:') {
21
+ throw new Error('Tunnel URL must use HTTPS');
22
+ }
23
+ return assertSafeHostname(parsed.hostname, 'tunnel hostname');
9
24
  }
10
25
 
11
26
  export function quoteRemoteShell(value) {
@@ -25,8 +40,9 @@ export function formatScpRemotePath(remotePath) {
25
40
  }
26
41
 
27
42
  export function getSshControlOptions(hostname) {
43
+ const safeHostname = assertSafeHostname(hostname, 'ssh hostname');
28
44
  if (process.platform === 'win32') return [];
29
- const hash = crypto.createHash('sha256').update(hostname).digest('hex').slice(0, 10);
45
+ const hash = crypto.createHash('sha256').update(safeHostname).digest('hex').slice(0, 10);
30
46
  return [
31
47
  '-o', 'ControlMaster=auto',
32
48
  '-o', 'ControlPersist=5m',
@@ -34,6 +50,11 @@ export function getSshControlOptions(hostname) {
34
50
  ];
35
51
  }
36
52
 
53
+ export function buildProxyCommandOption(hostname) {
54
+ const safeHostname = assertSafeHostname(hostname, 'tunnel hostname');
55
+ return ['-o', `ProxyCommand=cloudflared access tcp --hostname ${safeHostname}`];
56
+ }
57
+
37
58
  export function getKnownHostsOptions(persistKnownHosts = true) {
38
59
  if (persistKnownHosts) {
39
60
  return ['-o', 'StrictHostKeyChecking=accept-new'];
@@ -48,9 +69,8 @@ export function getKnownHostsOptions(persistKnownHosts = true) {
48
69
 
49
70
  export function buildSshArgs(hostname, privateKeyPath, extraOptions = [], options = {}) {
50
71
  const { persistKnownHosts = true } = options;
51
- const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
52
72
  const sshArgs = [
53
- '-o', `ProxyCommand=${proxyCommand}`,
73
+ ...buildProxyCommandOption(hostname),
54
74
  ...getKnownHostsOptions(persistKnownHosts),
55
75
  '-o', 'IdentitiesOnly=yes',
56
76
  ...getSshControlOptions(hostname),
package/src/lib/tunnel.js CHANGED
@@ -15,6 +15,7 @@ export async function spawnTunnelSupervised(targetUrl, onUrlGenerated) {
15
15
  activeChild = execa('cloudflared', ['tunnel', '--url', targetUrl], {
16
16
  reject: false,
17
17
  all: true,
18
+ buffer: false,
18
19
  });
19
20
 
20
21
  trackPID(activeChild.pid);
@@ -0,0 +1,81 @@
1
+ import { Worker } from 'node:worker_threads';
2
+
3
+ const workerUrl = new URL('./workers/crypto-checksum-worker.js', import.meta.url);
4
+ const workersDisabled = process.env.IPINGYOU_DISABLE_WORKERS === '1';
5
+ const MAX_PENDING_TASKS = 128;
6
+
7
+ let worker = null;
8
+ let requestCounter = 0;
9
+ const pending = new Map();
10
+
11
+ function resetWorker(err) {
12
+ for (const { reject } of pending.values()) {
13
+ reject(err);
14
+ }
15
+ pending.clear();
16
+ worker = null;
17
+ }
18
+
19
+ function ensureWorker() {
20
+ if (worker || workersDisabled) return worker;
21
+
22
+ worker = new Worker(workerUrl, {
23
+ resourceLimits: {
24
+ maxOldGenerationSizeMb: 128,
25
+ },
26
+ });
27
+ worker.unref();
28
+
29
+ worker.on('message', (message) => {
30
+ const { id, ok, result, error } = message || {};
31
+ const entry = pending.get(id);
32
+ if (!entry) return;
33
+ pending.delete(id);
34
+ if (pending.size === 0) worker?.unref();
35
+ if (ok) {
36
+ entry.resolve(result);
37
+ return;
38
+ }
39
+ entry.reject(new Error(error || 'Worker task failed'));
40
+ });
41
+
42
+ worker.on('error', (err) => {
43
+ resetWorker(err);
44
+ });
45
+
46
+ worker.on('exit', (code) => {
47
+ if (code !== 0) {
48
+ resetWorker(new Error(`Worker exited with code ${code}`));
49
+ } else {
50
+ worker = null;
51
+ }
52
+ });
53
+
54
+ return worker;
55
+ }
56
+
57
+ export function canUseWorkers() {
58
+ return !workersDisabled;
59
+ }
60
+
61
+ export function runWorkerTask(type, payload) {
62
+ if (!canUseWorkers()) {
63
+ throw new Error('Worker threads are disabled');
64
+ }
65
+ const activeWorker = ensureWorker();
66
+ if (!activeWorker) {
67
+ throw new Error('Worker is not available');
68
+ }
69
+ if (pending.size >= MAX_PENDING_TASKS) {
70
+ const error = new Error('Worker task queue is at capacity');
71
+ error.code = 'WORKER_QUEUE_FULL';
72
+ throw error;
73
+ }
74
+
75
+ const id = ++requestCounter;
76
+ return new Promise((resolve, reject) => {
77
+ pending.set(id, { resolve, reject });
78
+ activeWorker.ref();
79
+ activeWorker.postMessage({ id, type, payload });
80
+ });
81
+ }
@@ -0,0 +1,70 @@
1
+ import { parentPort } from 'node:worker_threads';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+
5
+ function deriveKey(password, salt) {
6
+ return crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
7
+ }
8
+
9
+ function encryptPayload(plaintext, password) {
10
+ const salt = crypto.randomBytes(16);
11
+ const key = deriveKey(password, salt);
12
+ const iv = crypto.randomBytes(16);
13
+
14
+ const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
15
+ let enc = cipher.update(plaintext, 'utf8', 'base64');
16
+ enc += cipher.final('base64');
17
+
18
+ return {
19
+ iv: iv.toString('hex'),
20
+ ciphertext: enc,
21
+ salt: salt.toString('hex'),
22
+ };
23
+ }
24
+
25
+ function decryptPayload(ivHex, cipherBase64, password, saltHex) {
26
+ const salt = Buffer.from(saltHex, 'hex');
27
+ const key = deriveKey(password, salt);
28
+ const iv = Buffer.from(ivHex, 'hex');
29
+ const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
30
+ let dec = decipher.update(cipherBase64, 'base64', 'utf8');
31
+ dec += decipher.final('utf8');
32
+ return dec;
33
+ }
34
+
35
+ function checksumFile(filePath) {
36
+ return new Promise((resolve, reject) => {
37
+ const hash = crypto.createHash('sha256');
38
+ const stream = fs.createReadStream(filePath);
39
+ stream.on('error', reject);
40
+ stream.on('data', (chunk) => hash.update(chunk));
41
+ stream.on('end', () => resolve(hash.digest('hex')));
42
+ });
43
+ }
44
+
45
+ async function executeTask(type, payload) {
46
+ switch (type) {
47
+ case 'encrypt':
48
+ return encryptPayload(payload.plaintext, payload.password);
49
+ case 'decrypt':
50
+ return {
51
+ plaintext: decryptPayload(payload.ivHex, payload.cipherBase64, payload.password, payload.saltHex),
52
+ };
53
+ case 'checksum':
54
+ return {
55
+ digest: await checksumFile(payload.filePath),
56
+ };
57
+ default:
58
+ throw new Error(`Unsupported worker task: ${type}`);
59
+ }
60
+ }
61
+
62
+ parentPort.on('message', async (message) => {
63
+ const { id, type, payload } = message || {};
64
+ try {
65
+ const result = await executeTask(type, payload || {});
66
+ parentPort.postMessage({ id, ok: true, result });
67
+ } catch (err) {
68
+ parentPort.postMessage({ id, ok: false, error: err.message });
69
+ }
70
+ });
package/src/modes/ai.js CHANGED
@@ -574,12 +574,10 @@ async function tryAITransfer(task, context) {
574
574
  if (context && context.scope === 'remote' && context.hostname && context.username) {
575
575
  console.log(chalk.dim(` Using active remote session: ${context.username}@${context.hostname}`));
576
576
 
577
- const { getSshControlOptions, formatScpRemotePath } = await import('../lib/ssh.js');
578
-
579
- const proxyCommand = `cloudflared access tcp --hostname ${context.hostname}`;
577
+ const { buildProxyCommandOption, getSshControlOptions, formatScpRemotePath } = await import('../lib/ssh.js');
580
578
  const scpArgs = [
581
579
  '-r',
582
- '-o', `ProxyCommand=${proxyCommand}`,
580
+ ...buildProxyCommandOption(context.hostname),
583
581
  '-o', 'StrictHostKeyChecking=accept-new',
584
582
  '-o', 'IdentitiesOnly=yes',
585
583
  ...getSshControlOptions(context.hostname),
@@ -630,4 +628,3 @@ async function tryAITransfer(task, context) {
630
628
  return true;
631
629
  }
632
630
  }
633
-
@@ -24,7 +24,7 @@ import { getConfig, saveAlias } from '../lib/config.js';
24
24
  import { pushTelemetry, requestHostApproval, resolveUID, revokeUID, waitForApproval } from '../lib/broker.js';
25
25
  import { calculateChecksum } from '../lib/checksum.js';
26
26
  import { promptLocalPath, promptRemotePath } from '../lib/path-browser.js';
27
- import { buildSshArgs, extractHostname, formatScpRemotePath, getKnownHostsOptions, getSshControlOptions, quoteRemoteShell } from '../lib/ssh.js';
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
29
  import open from 'open';
30
30
  import { secureSensitiveUrl } from '../lib/secure-print.js';
@@ -187,12 +187,10 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
187
187
 
188
188
  await showConnectionTrace('Local', 'Remote SCP');
189
189
 
190
- const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
191
-
192
190
  // Construct SCP args
193
191
  const scpArgs = [
194
192
  '-r', // recursive just in case
195
- '-o', `ProxyCommand=${proxyCommand}`,
193
+ ...buildProxyCommandOption(hostname),
196
194
  ...getKnownHostsOptions(persistKnownHosts),
197
195
  '-o', 'IdentitiesOnly=yes',
198
196
  ...getSshControlOptions(hostname)
@@ -223,6 +221,16 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
223
221
  const child = execa('scp', scpArgs, {
224
222
  stdio: ['inherit', 'pipe', 'pipe'],
225
223
  reject: false,
224
+ buffer: false,
225
+ });
226
+ const maxDiagnosticBytes = 64 * 1024;
227
+ let stderrOutput = Buffer.alloc(0);
228
+ child.stdout?.resume();
229
+ child.stderr?.on('data', (chunk) => {
230
+ const next = Buffer.concat([stderrOutput, Buffer.from(chunk)]);
231
+ stderrOutput = next.length > maxDiagnosticBytes
232
+ ? next.subarray(next.length - maxDiagnosticBytes)
233
+ : next;
226
234
  });
227
235
 
228
236
  trackPID(child.pid);
@@ -262,9 +270,10 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
262
270
  console.log(chalk.green(` ✅ Transfer completed successfully!`));
263
271
  recordEvent('scp_transfer_success', { direction, localPath, remotePath, hostname });
264
272
  } else {
273
+ const stderr = stderrOutput.toString('utf8');
265
274
  console.error(chalk.red(' ❌ SCP transfer failed'));
266
- if (result.stderr) console.error(chalk.dim(` ${result.stderr.trim()}`));
267
- recordEvent('scp_transfer_failed', { direction, localPath, remotePath, hostname, error: result.stderr });
275
+ if (stderr) console.error(chalk.dim(` ${stderr.trim()}`));
276
+ recordEvent('scp_transfer_failed', { direction, localPath, remotePath, hostname, error: stderr });
268
277
  }
269
278
  } catch (err) {
270
279
  console.error(chalk.red(` ❌ SCP error: ${err.message}`));
@@ -273,10 +282,9 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
273
282
 
274
283
  async function downloadSpecificRemotePath(username, hostname, privateKeyPath, remotePath, localPath, persistKnownHosts = true) {
275
284
  await showConnectionTrace('Local', 'Remote SCP');
276
- const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
277
285
  const scpArgs = [
278
286
  '-r',
279
- '-o', `ProxyCommand=${proxyCommand}`,
287
+ ...buildProxyCommandOption(hostname),
280
288
  ...getKnownHostsOptions(persistKnownHosts),
281
289
  '-o', 'IdentitiesOnly=yes',
282
290
  ...getSshControlOptions(hostname),
@@ -667,8 +675,7 @@ export async function performSCPNonInteractive(params = {}) {
667
675
  const privateKeyPath = payload.privateKey ? await writeEphemeralPrivateKey(payload.privateKey) : null;
668
676
 
669
677
  // Build scp args similar to performSCP
670
- const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
671
- const scpArgs = ['-r', '-o', `ProxyCommand=${proxyCommand}`, ...getKnownHostsOptions(persistKnownHosts), '-o', 'IdentitiesOnly=yes', ...getSshControlOptions(hostname)];
678
+ const scpArgs = ['-r', ...buildProxyCommandOption(hostname), ...getKnownHostsOptions(persistKnownHosts), '-o', 'IdentitiesOnly=yes', ...getSshControlOptions(hostname)];
672
679
  if (privateKeyPath) scpArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
673
680
 
674
681
  const remoteSpec = `${username}@${hostname}:${formatScpRemotePath(remotePath)}`;
package/src/modes/host.js CHANGED
@@ -18,10 +18,11 @@ 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';
23
24
  import { generateUID } from '../lib/uid.js';
24
- import { decrypt } from '../lib/crypto.js';
25
+ import { decryptAsync } from '../lib/crypto.js';
25
26
  import { cleanupAll, killProcessTree, trackPID, untrackPID, setRevokeOnExit, addCleanupHook } from '../lib/cleanup.js';
26
27
  import { detectOS } from '../lib/platform.js';
27
28
  import { createSpinner, networkSpinner, typeText } from '../lib/animations.js';
@@ -277,6 +278,44 @@ async function startLocalHostDashboard(uid, password, serviceConfig, sessionStat
277
278
  const { default: open } = await import('open');
278
279
  const app = express();
279
280
  const startedAt = new Date().toISOString();
281
+ const decryptedClientCache = new Map();
282
+ const MAX_DECRYPTED_CLIENT_CACHE = 100;
283
+ const activeEventStreams = new Set();
284
+ const MAX_EVENT_STREAMS = 5;
285
+
286
+ async function fetchDecryptedClients() {
287
+ const brokerRes = await fetch(`${BROKER_URL}/clients/${uid}`, {
288
+ headers: sessionState.hostToken ? { 'x-host-token': sessionState.hostToken } : {}
289
+ });
290
+ if (!brokerRes.ok) {
291
+ const data = await brokerRes.json().catch(() => ({}));
292
+ throw new Error(data.error || 'Failed to fetch clients');
293
+ }
294
+ const data = await brokerRes.json();
295
+ const activeCacheKeys = new Set();
296
+ const decryptedClients = await Promise.all((data.clients || []).map(async (clientBlob) => {
297
+ const cacheKey = `${clientBlob.iv}:${clientBlob.salt}:${clientBlob.ciphertext}`;
298
+ activeCacheKeys.add(cacheKey);
299
+ const cached = decryptedClientCache.get(cacheKey);
300
+ if (cached) return { ...cached, seenAt: clientBlob.seenAt || null };
301
+ try {
302
+ const decrypted = await decryptAsync(clientBlob.iv, clientBlob.ciphertext, password, clientBlob.salt);
303
+ const t = JSON.parse(decrypted);
304
+ decryptedClientCache.set(cacheKey, t);
305
+ return { ...t, seenAt: clientBlob.seenAt || null };
306
+ } catch {
307
+ const failed = { error: 'decrypt_failed' };
308
+ decryptedClientCache.set(cacheKey, failed);
309
+ return { ...failed, seenAt: clientBlob.seenAt || null };
310
+ }
311
+ }));
312
+ for (const key of decryptedClientCache.keys()) {
313
+ if (!activeCacheKeys.has(key) || decryptedClientCache.size > MAX_DECRYPTED_CLIENT_CACHE) {
314
+ decryptedClientCache.delete(key);
315
+ }
316
+ }
317
+ return { clients: decryptedClients };
318
+ }
280
319
 
281
320
  app.get('/api/status', (_req, res) => {
282
321
  res.json({
@@ -293,47 +332,91 @@ async function startLocalHostDashboard(uid, password, serviceConfig, sessionStat
293
332
 
294
333
  app.use(express.json());
295
334
 
335
+ app.get('/api/approvals', async (_req, res) => {
336
+ try {
337
+ const data = await fetchApprovalRequests(BROKER_URL, uid, sessionState.hostToken);
338
+ res.json(data);
339
+ } catch (err) {
340
+ res.status(500).json({ error: err.message });
341
+ }
342
+ });
343
+
296
344
  // Server-Sent Events for live telemetry & approvals
297
345
  app.get('/api/events', async (req, res) => {
346
+ if (activeEventStreams.size >= MAX_EVENT_STREAMS) {
347
+ return res.status(503).json({ error: 'Too many dashboard event streams' });
348
+ }
349
+ activeEventStreams.add(res);
298
350
  res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
299
351
  res.flushHeaders?.();
300
352
 
301
353
  let closed = false;
302
- const push = async () => {
354
+ let timer = null;
355
+ let intervalMs = 5000;
356
+ let unchangedCycles = 0;
357
+ let lastApprovals = '';
358
+ let lastClients = '';
359
+
360
+ const writeEvent = (event, payload) => {
361
+ if (closed) return;
362
+ res.write(`event: ${event}\n`);
363
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
364
+ };
365
+
366
+ const scheduleNext = () => {
367
+ if (closed) return;
368
+ timer = setTimeout(pushLoop, intervalMs);
369
+ };
370
+
371
+ const pushLoop = async () => {
303
372
  if (closed) return;
304
373
  try {
305
- const data = await fetchApprovalRequests(BROKER_URL, uid, sessionState.hostToken).catch(() => ({ approvals: [] }));
306
- res.write(`event: approvals\n`);
307
- res.write(`data: ${JSON.stringify(data)}\n\n`);
308
- } catch { }
374
+ const [approvalData, clientData] = await Promise.all([
375
+ fetchApprovalRequests(BROKER_URL, uid, sessionState.hostToken).catch(() => ({ approvals: [] })),
376
+ fetchDecryptedClients().catch(() => ({ clients: [] })),
377
+ ]);
378
+
379
+ const approvalsPayload = { approvals: approvalData.approvals || [] };
380
+ const clientsPayload = { clients: clientData.clients || [] };
381
+ const approvalsHash = JSON.stringify(approvalsPayload.approvals);
382
+ const clientsHash = JSON.stringify(clientsPayload.clients);
383
+ const changed = approvalsHash !== lastApprovals || clientsHash !== lastClients;
384
+
385
+ if (approvalsHash !== lastApprovals) {
386
+ writeEvent('approvals', approvalsPayload);
387
+ lastApprovals = approvalsHash;
388
+ }
389
+ if (clientsHash !== lastClients) {
390
+ writeEvent('clients', clientsPayload);
391
+ lastClients = clientsHash;
392
+ }
393
+
394
+ if (changed) {
395
+ unchangedCycles = 0;
396
+ intervalMs = 5000;
397
+ } else {
398
+ unchangedCycles += 1;
399
+ intervalMs = Math.min(20000, 5000 * (2 ** Math.min(2, unchangedCycles)));
400
+ }
401
+ } finally {
402
+ scheduleNext();
403
+ }
309
404
  };
310
405
 
311
- const iv = setInterval(push, 5000);
312
- req.on('close', () => { clearInterval(iv); closed = true; });
313
- // send initial payload
314
- await push();
406
+ req.on('close', () => {
407
+ closed = true;
408
+ activeEventStreams.delete(res);
409
+ if (timer) clearTimeout(timer);
410
+ });
411
+
412
+ writeEvent('ready', { ok: true });
413
+ await pushLoop();
315
414
  });
316
415
 
317
416
  app.get('/api/clients', async (_req, res) => {
318
417
  try {
319
- const brokerRes = await fetch(`${BROKER_URL}/clients/${uid}`, {
320
- headers: sessionState.hostToken ? { 'x-host-token': sessionState.hostToken } : {}
321
- });
322
- if (!brokerRes.ok) {
323
- const data = await brokerRes.json().catch(() => ({}));
324
- return res.status(brokerRes.status).json({ error: data.error || 'Failed to fetch clients' });
325
- }
326
- const data = await brokerRes.json();
327
- const clients = (data.clients || []).map((clientBlob) => {
328
- try {
329
- const decrypted = decrypt(clientBlob.iv, clientBlob.ciphertext, password, clientBlob.salt);
330
- const t = JSON.parse(decrypted);
331
- return { ...t, seenAt: clientBlob.seenAt || null };
332
- } catch {
333
- return { error: 'decrypt_failed', seenAt: clientBlob.seenAt || null };
334
- }
335
- });
336
- res.json({ clients });
418
+ const clients = await fetchDecryptedClients();
419
+ res.json(clients);
337
420
  } catch (err) {
338
421
  res.status(500).json({ error: err.message });
339
422
  }
@@ -486,12 +569,44 @@ updateUptime();
486
569
 
487
570
  // SSE for live updates
488
571
  const es = new EventSource('/api/events');
572
+ let fallbackTimer = null;
573
+ let fallbackDelay = 5000;
574
+
575
+ function scheduleFallbackPoll() {
576
+ if (fallbackTimer) return;
577
+ fallbackTimer = setTimeout(async function pollFallback() {
578
+ fallbackTimer = null;
579
+ try {
580
+ const [approvalsRes, clientsRes] = await Promise.all([
581
+ fetch('/api/approvals'),
582
+ fetch('/api/clients')
583
+ ]);
584
+ if (approvalsRes.ok) renderApprovals((await approvalsRes.json()).approvals || []);
585
+ if (clientsRes.ok) renderClients((await clientsRes.json()).clients || []);
586
+ } catch {}
587
+ fallbackDelay = Math.min(20000, fallbackDelay * 2);
588
+ scheduleFallbackPoll();
589
+ }, fallbackDelay);
590
+ }
591
+
592
+ es.addEventListener('open', () => {
593
+ fallbackDelay = 5000;
594
+ if (fallbackTimer) clearTimeout(fallbackTimer);
595
+ fallbackTimer = null;
596
+ });
597
+ es.addEventListener('error', scheduleFallbackPoll);
489
598
  es.addEventListener('approvals', (e) => {
490
599
  try {
491
600
  const data = JSON.parse(e.data);
492
601
  renderApprovals(data.approvals || []);
493
602
  } catch {}
494
603
  });
604
+ es.addEventListener('clients', (e) => {
605
+ try {
606
+ const data = JSON.parse(e.data);
607
+ renderClients(data.clients || []);
608
+ } catch {}
609
+ });
495
610
 
496
611
  function renderApprovals(approvals) {
497
612
  const container = document.getElementById('approvals');
@@ -556,16 +671,6 @@ async function revokeSession() {
556
671
  } catch (err) { showToast('Error: ' + err.message); }
557
672
  }
558
673
 
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
674
  function renderClients(clients) {
570
675
  const container = document.getElementById('clients');
571
676
  if (!clients || clients.length === 0) {
@@ -595,8 +700,6 @@ function renderClients(clients) {
595
700
  container.innerHTML = html;
596
701
  }
597
702
 
598
- setInterval(pollClients, 5000);
599
- pollClients();
600
703
  </script>
601
704
  </body></html>`);
602
705
  });
@@ -618,18 +721,67 @@ pollClients();
618
721
  async function spawnPrivateBroker() {
619
722
  console.log(chalk.yellow('\n ⚠️ Public Broker is unreachable. Spawning Private Broker...'));
620
723
 
724
+ const packageRoot = path.resolve(__dirname, '../..');
725
+ const serverEntrypoint = path.join(__dirname, '../server.js');
726
+ const requireFromServer = createRequire(serverEntrypoint);
727
+ const requiredBrokerPackages = ['express', 'express-rate-limit', 'helmet'];
728
+ const missingPackages = requiredBrokerPackages.filter((pkg) => {
729
+ try {
730
+ requireFromServer.resolve(pkg);
731
+ return false;
732
+ } catch {
733
+ return true;
734
+ }
735
+ });
736
+
737
+ if (missingPackages.length > 0) {
738
+ console.log(chalk.yellow(` ⚠️ Missing broker runtime packages: ${missingPackages.join(', ')}`));
739
+ const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
740
+ const installResult = await execa(
741
+ npmCmd,
742
+ ['install', '--no-save', '--no-audit', '--no-fund', ...missingPackages],
743
+ { cwd: packageRoot, reject: false, all: true }
744
+ );
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
+ }
767
+
621
768
  // 1. Spawn the broker server process
622
- const brokerProcess = execa('node', [path.join(__dirname, '../server.js')], {
769
+ const brokerProcess = execa('node', [serverEntrypoint], {
623
770
  env: { ...process.env, PORT: '4040' },
624
771
  reject: false,
625
772
  all: true,
773
+ buffer: false,
626
774
  });
627
775
  trackPID(brokerProcess.pid);
628
776
 
629
777
  let brokerExited = false;
630
- let brokerOutput = '';
778
+ const maxBrokerOutputBytes = 64 * 1024;
779
+ let brokerOutput = Buffer.alloc(0);
631
780
  brokerProcess.all?.on('data', chunk => {
632
- brokerOutput += chunk.toString();
781
+ const next = Buffer.concat([brokerOutput, Buffer.from(chunk)]);
782
+ brokerOutput = next.length > maxBrokerOutputBytes
783
+ ? next.subarray(next.length - maxBrokerOutputBytes)
784
+ : next;
633
785
  });
634
786
  brokerProcess.on('exit', () => {
635
787
  brokerExited = true;
@@ -643,7 +795,8 @@ async function spawnPrivateBroker() {
643
795
 
644
796
  await waitForValue(() => {
645
797
  if (brokerExited) {
646
- throw new Error(`Private broker exited before tunnel was ready${brokerOutput ? `: ${brokerOutput.trim()}` : ''}`);
798
+ const output = brokerOutput.toString('utf8').trim();
799
+ throw new Error(`Private broker exited before tunnel was ready${output ? `: ${output}` : ''}`);
647
800
  }
648
801
  return brokerTunnelUrl;
649
802
  }, 30000, 'Private broker tunnel startup');
@@ -753,7 +906,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
753
906
  for (const request of pending) {
754
907
  let details = {};
755
908
  try {
756
- details = JSON.parse(decrypt(request.iv, request.ciphertext, password, request.salt));
909
+ details = JSON.parse(await decryptAsync(request.iv, request.ciphertext, password, request.salt));
757
910
  } catch {
758
911
  details = { error: 'Could not decrypt request metadata' };
759
912
  }
@@ -893,10 +1046,10 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
893
1046
  } else {
894
1047
  spinner.succeed(`Found ${data.clients.length} recent connection(s):`);
895
1048
 
896
- data.clients.forEach((clientBlob, i) => {
1049
+ for (const [i, clientBlob] of data.clients.entries()) {
897
1050
  try {
898
1051
  // Decrypt using the unique salt the client generated for this payload
899
- const decrypted = decrypt(clientBlob.iv, clientBlob.ciphertext, password, clientBlob.salt);
1052
+ const decrypted = await decryptAsync(clientBlob.iv, clientBlob.ciphertext, password, clientBlob.salt);
900
1053
  const t = JSON.parse(decrypted);
901
1054
 
902
1055
  console.log(chalk.bold.blue(`\n Client #${i + 1} (${t.username})`));
@@ -910,7 +1063,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
910
1063
  console.log(chalk.yellow(`\n Client #${i + 1}: Payload decryption failed (wrong password or corrupted).`));
911
1064
  logSessionEvent('host_telemetry_decrypt_failed', { index: i + 1 }, 'warn');
912
1065
  }
913
- });
1066
+ }
914
1067
  }
915
1068
  } catch (e) {
916
1069
  spinner.fail('Could not reach broker.');
package/src/server.js CHANGED
@@ -96,8 +96,8 @@ const hostLimiter = rateLimit({
96
96
  });
97
97
 
98
98
  // ─── Active Defense & IP Blacklisting (IDS) ──────────────────
99
- const ipViolations = new Map(); // ip → violation_count
100
- const blacklistedIPs = new Set();
99
+ const ipViolations = new Map(); // ip → { count, lastSeen }
100
+ const blacklistedIPs = new Map(); // ip → bannedAt
101
101
  const VIOLATION_THRESHOLD = 5; // Block IP after 5 malicious requests
102
102
 
103
103
  app.use((req, res, next) => {
@@ -111,12 +111,12 @@ app.use((req, res, next) => {
111
111
 
112
112
  function recordViolation(req) {
113
113
  const ip = req.ip || req.connection.remoteAddress;
114
- const count = (ipViolations.get(ip) || 0) + 1;
115
- ipViolations.set(ip, count);
114
+ const count = (ipViolations.get(ip)?.count || 0) + 1;
115
+ ipViolations.set(ip, { count, lastSeen: Date.now() });
116
116
  console.warn(`🚨 Violation recorded for IP ${ip} (${count}/${VIOLATION_THRESHOLD})`);
117
117
 
118
118
  if (count >= VIOLATION_THRESHOLD) {
119
- blacklistedIPs.add(ip);
119
+ blacklistedIPs.set(ip, Date.now());
120
120
  console.error(`💥 HACKING DETECTED: Auto-banned IP ${ip} to defend server.`);
121
121
  }
122
122
  }
@@ -126,9 +126,19 @@ const TTL_MS = 60 * 60 * 1000; // 1 hour
126
126
  const MAX_UIDS = 50000; // Max concurrent tunnels (prevent memory leak)
127
127
  const MAX_VIOLATIONS = 50000; // Max tracked malicious IPs before reset
128
128
  const MAX_RESOLVES_PER_UID = 100; // Max resolves before auto-revoke (anti-scraping)
129
+ const MAX_APPROVALS_PER_UID = 50;
130
+ const MAX_CLIENTS_PER_UID = 50;
131
+ const configuredPayloadMb = Number.parseInt(process.env.BROKER_MAX_PAYLOAD_MB || '64', 10);
132
+ const MAX_BUFFERED_PAYLOAD_BYTES = (
133
+ Number.isFinite(configuredPayloadMb) && configuredPayloadMb > 0 ? configuredPayloadMb : 64
134
+ ) * 1024 * 1024;
135
+ const APPROVAL_TTL_MS = TTL_MS;
136
+ const TELEMETRY_TTL_MS = 30 * 60 * 1000;
137
+ const VIOLATION_TTL_MS = 24 * 60 * 60 * 1000;
129
138
  const BROKER_SECRET = process.env.BROKER_HMAC_SECRET || crypto.randomBytes(32).toString('hex');
130
139
 
131
140
  const store = new Map(); // uid → { iv, ciphertext, salt, createdAt, clients: [], approvals: [], hostToken, resolveCount }
141
+ let bufferedPayloadBytes = 0;
132
142
 
133
143
  // ─── Security Helpers ────────────────────────────────────────
134
144
  const SAFE_PARAM = /^[a-zA-Z0-9_-]{1,64}$/;
@@ -161,22 +171,61 @@ function isEncryptedPayload(body) {
161
171
  && /^[A-Za-z0-9+/]+={0,2}$/.test(body.ciphertext);
162
172
  }
163
173
 
174
+ function encryptedPayloadBytes(payload) {
175
+ return Buffer.byteLength(payload?.ciphertext || '')
176
+ + Buffer.byteLength(payload?.iv || '')
177
+ + Buffer.byteLength(payload?.salt || '');
178
+ }
179
+
180
+ function entryPayloadBytes(entry) {
181
+ if (!entry) return 0;
182
+ return encryptedPayloadBytes(entry)
183
+ + entry.clients.reduce((sum, item) => sum + encryptedPayloadBytes(item), 0)
184
+ + entry.approvals.reduce((sum, item) => sum + encryptedPayloadBytes(item), 0);
185
+ }
186
+
187
+ function deleteStoreEntry(uid) {
188
+ const entry = store.get(uid);
189
+ if (!entry) return false;
190
+ bufferedPayloadBytes = Math.max(0, bufferedPayloadBytes - entryPayloadBytes(entry));
191
+ return store.delete(uid);
192
+ }
193
+
164
194
  function pruneExpired() {
165
195
  const now = Date.now();
166
196
  for (const [uid, entry] of store) {
167
197
  if (now - entry.createdAt > TTL_MS) {
168
- store.delete(uid);
198
+ deleteStoreEntry(uid);
169
199
  console.log(`🗑️ Expired UID: ${uid}`);
200
+ continue;
170
201
  }
202
+ const previousBytes = entryPayloadBytes(entry);
203
+ entry.approvals = entry.approvals
204
+ .filter(item => now - item.createdAt <= APPROVAL_TTL_MS)
205
+ .slice(-MAX_APPROVALS_PER_UID);
206
+ entry.clients = entry.clients
207
+ .filter(item => now - item.seenAt <= TELEMETRY_TTL_MS)
208
+ .slice(-MAX_CLIENTS_PER_UID);
209
+ bufferedPayloadBytes = Math.max(0, bufferedPayloadBytes - previousBytes + entryPayloadBytes(entry));
171
210
  }
172
211
 
173
- // Strict check to prevent malicious OOM overflow
174
- if (ipViolations.size > MAX_VIOLATIONS) ipViolations.clear();
175
- if (blacklistedIPs.size > MAX_VIOLATIONS) blacklistedIPs.clear();
212
+ for (const [ip, violation] of ipViolations) {
213
+ if (now - violation.lastSeen > VIOLATION_TTL_MS) ipViolations.delete(ip);
214
+ }
215
+ for (const [ip, bannedAt] of blacklistedIPs) {
216
+ if (now - bannedAt > VIOLATION_TTL_MS) blacklistedIPs.delete(ip);
217
+ }
218
+ while (ipViolations.size > MAX_VIOLATIONS) {
219
+ ipViolations.delete(ipViolations.keys().next().value);
220
+ }
221
+ while (blacklistedIPs.size > MAX_VIOLATIONS) {
222
+ blacklistedIPs.delete(blacklistedIPs.keys().next().value);
223
+ }
176
224
  }
177
225
 
178
226
  // Run pruning every 5 minutes
179
- setInterval(pruneExpired, 5 * 60 * 1000);
227
+ const pruneTimer = setInterval(pruneExpired, 5 * 60 * 1000);
228
+ pruneTimer.unref?.();
180
229
 
181
230
  // ─── Routes ──────────────────────────────────────────────────
182
231
 
@@ -227,6 +276,11 @@ app.post('/register', strictLimiter, (req, res) => {
227
276
  if (store.size >= MAX_UIDS && !store.has(uid)) {
228
277
  return res.status(503).json({ error: 'Broker is at maximum capacity. Please try again later.' });
229
278
  }
279
+ const incomingBytes = encryptedPayloadBytes({ iv, ciphertext, salt });
280
+ const replacedBytes = entryPayloadBytes(store.get(uid));
281
+ if (bufferedPayloadBytes - replacedBytes + incomingBytes > MAX_BUFFERED_PAYLOAD_BYTES) {
282
+ return res.status(503).json({ error: 'Broker encrypted payload capacity reached. Please try again later.' });
283
+ }
230
284
 
231
285
  // Use provided host token if valid; otherwise generate a fresh one
232
286
  const hostToken = (typeof providedHostToken === 'string' && HOST_TOKEN_FORMAT.test(providedHostToken))
@@ -234,6 +288,7 @@ app.post('/register', strictLimiter, (req, res) => {
234
288
  : generateHostToken(uid + Date.now().toString());
235
289
 
236
290
  // Store the encrypted blob as-is — broker NEVER decrypts
291
+ if (store.has(uid)) deleteStoreEntry(uid);
237
292
  store.set(uid, {
238
293
  iv,
239
294
  ciphertext,
@@ -246,6 +301,7 @@ app.post('/register', strictLimiter, (req, res) => {
246
301
  hostToken,
247
302
  resolveCount: 0,
248
303
  });
304
+ bufferedPayloadBytes += incomingBytes;
249
305
 
250
306
  console.log(`✅ [${new Date().toLocaleTimeString()}] Registered UID: ${uid} (encrypted, ${ciphertext.length} bytes)`);
251
307
  // Return host token — this is the ONLY time it's sent; host must store it
@@ -277,19 +333,19 @@ app.get('/resolve/:uid', generalLimiter, (req, res) => {
277
333
 
278
334
  // Check TTL
279
335
  if (Date.now() - entry.createdAt > TTL_MS) {
280
- store.delete(uid);
336
+ deleteStoreEntry(uid);
281
337
  return res.status(410).json({ error: 'UID expired' });
282
338
  }
283
339
 
284
340
  // Enforce one-time sharing: check BEFORE sending response (prevents race condition)
285
341
  if (entry.oneTime && entry.resolveCount > 0) {
286
- store.delete(uid);
342
+ deleteStoreEntry(uid);
287
343
  return res.status(410).json({ error: 'One-time session already consumed' });
288
344
  }
289
345
 
290
346
  // Anti-scraping: cap total resolves per UID
291
347
  if (entry.resolveCount >= MAX_RESOLVES_PER_UID) {
292
- store.delete(uid);
348
+ deleteStoreEntry(uid);
293
349
  return res.status(429).json({ error: 'Resolve limit exceeded — session revoked for security' });
294
350
  }
295
351
 
@@ -315,7 +371,7 @@ app.get('/resolve/:uid', generalLimiter, (req, res) => {
315
371
 
316
372
  // Enforce one-time sharing: auto-delete AFTER response sent
317
373
  if (entry.oneTime) {
318
- store.delete(uid);
374
+ deleteStoreEntry(uid);
319
375
  console.log(`🔒 [${new Date().toLocaleTimeString()}] One-time UID ${uid} auto-revoked after first resolve`);
320
376
  }
321
377
  } catch (err) {
@@ -354,8 +410,18 @@ app.post('/approval-request/:uid', generalLimiter, (req, res) => {
354
410
  decidedAt: entry.approvalRequired ? null : Date.now(),
355
411
  };
356
412
 
413
+ const requestBytes = encryptedPayloadBytes(request);
414
+ const replacedBytes = entry.approvals.length >= MAX_APPROVALS_PER_UID
415
+ ? encryptedPayloadBytes(entry.approvals[0])
416
+ : 0;
417
+ if (bufferedPayloadBytes - replacedBytes + requestBytes > MAX_BUFFERED_PAYLOAD_BYTES) {
418
+ return res.status(503).json({ error: 'Broker encrypted payload capacity reached. Please try again later.' });
419
+ }
357
420
  entry.approvals.push(request);
358
- if (entry.approvals.length > 50) entry.approvals.shift();
421
+ bufferedPayloadBytes += requestBytes;
422
+ if (entry.approvals.length > MAX_APPROVALS_PER_UID) {
423
+ bufferedPayloadBytes -= encryptedPayloadBytes(entry.approvals.shift());
424
+ }
359
425
  res.json({ requestId: id, status: request.status, approvalRequired: entry.approvalRequired });
360
426
  } catch {
361
427
  res.status(500).json({ error: 'Internal server error' });
@@ -426,10 +492,21 @@ app.post('/client-info/:uid', generalLimiter, (req, res) => {
426
492
  return res.status(400).json({ error: 'Invalid encrypted telemetry payload' });
427
493
  }
428
494
 
429
- entry.clients.push({ iv: req.body.iv, ciphertext: req.body.ciphertext, salt: req.body.salt, seenAt: Date.now() });
495
+ const clientRecord = { iv: req.body.iv, ciphertext: req.body.ciphertext, salt: req.body.salt, seenAt: Date.now() };
496
+ const clientBytes = encryptedPayloadBytes(clientRecord);
497
+ const replacedBytes = entry.clients.length >= MAX_CLIENTS_PER_UID
498
+ ? encryptedPayloadBytes(entry.clients[0])
499
+ : 0;
500
+ if (bufferedPayloadBytes - replacedBytes + clientBytes > MAX_BUFFERED_PAYLOAD_BYTES) {
501
+ return res.status(503).json({ error: 'Broker encrypted payload capacity reached. Please try again later.' });
502
+ }
503
+ entry.clients.push(clientRecord);
504
+ bufferedPayloadBytes += clientBytes;
430
505
 
431
506
  // Keep max 50 recent client pings to prevent memory leaks
432
- if (entry.clients.length > 50) entry.clients.shift();
507
+ if (entry.clients.length > MAX_CLIENTS_PER_UID) {
508
+ bufferedPayloadBytes -= encryptedPayloadBytes(entry.clients.shift());
509
+ }
433
510
 
434
511
  res.json({ status: 'ok' });
435
512
  } catch (err) {
@@ -470,7 +547,7 @@ app.delete('/revoke/:uid', strictLimiter, (req, res) => {
470
547
  const entry = store.get(uid);
471
548
  if (!entry) return res.json({ status: 'not_found' });
472
549
  if (!requireHostToken(req, res, entry)) return;
473
- store.delete(uid);
550
+ deleteStoreEntry(uid);
474
551
  console.log(`🚫 [${new Date().toLocaleTimeString()}] Revoked UID: ${uid} (authenticated)`);
475
552
  res.json({ status: 'revoked' });
476
553
  });