@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 +4 -1
- package/src/cli.js +22 -19
- package/src/lib/broker.js +5 -5
- package/src/lib/chat.js +7 -1
- package/src/lib/checksum.js +22 -2
- package/src/lib/crypto.js +27 -0
- package/src/lib/secure-print.js +7 -1
- package/src/lib/session-log.js +78 -3
- package/src/lib/ssh.js +25 -5
- package/src/lib/tunnel.js +1 -0
- package/src/lib/worker-runtime.js +81 -0
- package/src/lib/workers/crypto-checksum-worker.js +70 -0
- package/src/modes/ai.js +2 -5
- package/src/modes/client.js +17 -10
- package/src/modes/host.js +201 -48
- package/src/server.js +95 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miraj181/ipingyou",
|
|
3
|
-
"version": "2.1.
|
|
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
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
})
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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: ${
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 } =
|
|
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
|
-
|
|
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;
|
package/src/lib/checksum.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/lib/secure-print.js
CHANGED
|
@@ -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
|
-
|
|
57
|
+
lastMaskedFingerprint = fingerprint;
|
|
58
|
+
lastMaskedResult = `[pbkdf2:${hash.slice(0, 12)}…]`;
|
|
59
|
+
return lastMaskedResult;
|
|
54
60
|
}
|
|
55
61
|
|
|
56
62
|
/**
|
package/src/lib/session-log.js
CHANGED
|
@@ -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')
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
+
parsed = new URL(url);
|
|
6
17
|
} catch {
|
|
7
|
-
|
|
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(
|
|
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
|
-
|
|
73
|
+
...buildProxyCommandOption(hostname),
|
|
54
74
|
...getKnownHostsOptions(persistKnownHosts),
|
|
55
75
|
'-o', 'IdentitiesOnly=yes',
|
|
56
76
|
...getSshControlOptions(hostname),
|
package/src/lib/tunnel.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
package/src/modes/client.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
267
|
-
recordEvent('scp_transfer_failed', { direction, localPath, remotePath, hostname, error:
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
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 });
|
|
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', [
|
|
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
|
-
|
|
778
|
+
const maxBrokerOutputBytes = 64 * 1024;
|
|
779
|
+
let brokerOutput = Buffer.alloc(0);
|
|
631
780
|
brokerProcess.all?.on('data', chunk => {
|
|
632
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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 =
|
|
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 →
|
|
100
|
-
const blacklistedIPs = new
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 >
|
|
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
|
-
|
|
550
|
+
deleteStoreEntry(uid);
|
|
474
551
|
console.log(`🚫 [${new Date().toLocaleTimeString()}] Revoked UID: ${uid} (authenticated)`);
|
|
475
552
|
res.json({ status: 'revoked' });
|
|
476
553
|
});
|