@miraj181/ipingyou 2.0.9 → 2.0.12
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 +1 -1
- package/src/cli.js +10 -15
- package/src/lib/broker.js +144 -0
- package/src/lib/checksum.js +12 -0
- package/src/lib/cleanup.js +37 -15
- package/src/lib/config.js +7 -3
- package/src/lib/path-browser.js +232 -0
- package/src/lib/ssh.js +55 -0
- package/src/lib/tunnel.js +66 -0
- package/src/modes/client.js +49 -242
- package/src/modes/host.js +99 -144
- package/src/server.js +6 -1
package/src/modes/host.js
CHANGED
|
@@ -18,16 +18,31 @@ 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 fs from 'node:fs';
|
|
22
|
+
import os from 'node:os';
|
|
21
23
|
import { generateUID } from '../lib/uid.js';
|
|
22
|
-
import {
|
|
23
|
-
import { trackPID, untrackPID, setRevokeOnExit, addCleanupHook } from '../lib/cleanup.js';
|
|
24
|
+
import { decrypt } from '../lib/crypto.js';
|
|
25
|
+
import { cleanupAll, killProcessTree, trackPID, untrackPID, setRevokeOnExit, addCleanupHook } from '../lib/cleanup.js';
|
|
24
26
|
import { detectOS } from '../lib/platform.js';
|
|
25
|
-
import { createSpinner,
|
|
27
|
+
import { createSpinner, networkSpinner, typeText } from '../lib/animations.js';
|
|
26
28
|
import { startChatServer, openLocalChatUI } from '../lib/chat.js';
|
|
29
|
+
import { spawnTunnelSupervised } from '../lib/tunnel.js';
|
|
30
|
+
import { pingBroker, registerWithBroker } from '../lib/broker.js';
|
|
27
31
|
|
|
28
32
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
33
|
let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
|
|
30
34
|
|
|
35
|
+
async function waitForValue(getValue, timeoutMs, label) {
|
|
36
|
+
const startedAt = Date.now();
|
|
37
|
+
while (!getValue()) {
|
|
38
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
39
|
+
throw new Error(`${label} timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
40
|
+
}
|
|
41
|
+
await new Promise(r => setTimeout(r, 100));
|
|
42
|
+
}
|
|
43
|
+
return getValue();
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
/**
|
|
32
47
|
* Ensure the local SSH server is running.
|
|
33
48
|
*/
|
|
@@ -86,72 +101,47 @@ async function ensureSSHRunning() {
|
|
|
86
101
|
}
|
|
87
102
|
|
|
88
103
|
/**
|
|
89
|
-
*
|
|
104
|
+
* Ensure tmux is installed for terminal mirroring.
|
|
90
105
|
*/
|
|
91
|
-
async function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const loop = async () => {
|
|
96
|
-
while (!isShuttingDown) {
|
|
97
|
-
const spinner = createSpinner('Starting Cloudflare tunnel...', tunnelSpinner).start();
|
|
98
|
-
|
|
99
|
-
await new Promise((resolve) => {
|
|
100
|
-
activeChild = execa('cloudflared', ['tunnel', '--url', targetUrl], {
|
|
101
|
-
reject: false,
|
|
102
|
-
all: true,
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
trackPID(activeChild.pid);
|
|
106
|
-
let tunnelUrl = null;
|
|
107
|
-
let resolved = false;
|
|
108
|
-
|
|
109
|
-
activeChild.all.on('data', (chunk) => {
|
|
110
|
-
const text = chunk.toString();
|
|
111
|
-
const match = text.match(/https:\/\/[-0-9a-z]+\.trycloudflare\.com/);
|
|
112
|
-
if (match && !resolved) {
|
|
113
|
-
tunnelUrl = match[0];
|
|
114
|
-
resolved = true;
|
|
115
|
-
spinner.succeed(`Tunnel active: ${chalk.cyan(tunnelUrl)}`);
|
|
116
|
-
onUrlGenerated(tunnelUrl);
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
activeChild.on('exit', (code) => {
|
|
121
|
-
untrackPID(activeChild.pid);
|
|
122
|
-
if (!resolved) {
|
|
123
|
-
spinner.fail('Cloudflare tunnel exited before generating URL');
|
|
124
|
-
} else if (!isShuttingDown) {
|
|
125
|
-
console.log(chalk.yellow(`\n ⚠️ Tunnel disconnected (code ${code}). Restarting...`));
|
|
126
|
-
}
|
|
127
|
-
resolve(); // Let the loop continue to restart
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
activeChild.on('error', (err) => {
|
|
131
|
-
untrackPID(activeChild.pid);
|
|
132
|
-
spinner.fail(`Tunnel error: ${err.message}`);
|
|
133
|
-
resolve();
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
if (!isShuttingDown) {
|
|
138
|
-
// Wait a bit before restarting to avoid tight loop
|
|
139
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
loop(); // Fire and forget the supervisor loop
|
|
106
|
+
async function ensureTmuxInstalled() {
|
|
107
|
+
const osInfo = detectOS();
|
|
108
|
+
if (osInfo.isWindows) return;
|
|
145
109
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
110
|
+
const spinner = createSpinner('Checking tmux installation...', networkSpinner).start();
|
|
111
|
+
try {
|
|
112
|
+
try {
|
|
113
|
+
await execaCommand('tmux -V', { reject: true });
|
|
114
|
+
spinner.succeed('tmux is installed (Terminal Mirroring available)');
|
|
115
|
+
} catch {
|
|
116
|
+
spinner.text = 'tmux not found. Attempting to install...';
|
|
117
|
+
if (osInfo.isLinux) {
|
|
118
|
+
if (fs.existsSync('/usr/bin/apt') || fs.existsSync('/usr/bin/apt-get')) {
|
|
119
|
+
await execaCommand('sudo apt-get update && sudo apt-get install -y tmux', { shell: true, stdio: 'inherit' });
|
|
120
|
+
} else if (fs.existsSync('/usr/bin/dnf')) {
|
|
121
|
+
await execaCommand('sudo dnf install -y tmux', { shell: true, stdio: 'inherit' });
|
|
122
|
+
} else if (fs.existsSync('/usr/bin/yum')) {
|
|
123
|
+
await execaCommand('sudo yum install -y tmux', { shell: true, stdio: 'inherit' });
|
|
124
|
+
} else if (fs.existsSync('/usr/bin/pacman')) {
|
|
125
|
+
await execaCommand('sudo pacman -S --noconfirm tmux', { shell: true, stdio: 'inherit' });
|
|
126
|
+
} else if (fs.existsSync('/sbin/apk')) {
|
|
127
|
+
await execaCommand('sudo apk add tmux', { shell: true, stdio: 'inherit' });
|
|
128
|
+
} else {
|
|
129
|
+
throw new Error('Unsupported Linux package manager');
|
|
130
|
+
}
|
|
131
|
+
spinner.succeed('tmux installed successfully (Terminal Mirroring available)');
|
|
132
|
+
} else if (osInfo.isMac) {
|
|
133
|
+
try {
|
|
134
|
+
await execaCommand('brew install tmux', { shell: true, stdio: 'inherit' });
|
|
135
|
+
spinner.succeed('tmux installed successfully (Terminal Mirroring available)');
|
|
136
|
+
} catch {
|
|
137
|
+
throw new Error('Homebrew is required to install tmux on macOS');
|
|
138
|
+
}
|
|
152
139
|
}
|
|
153
140
|
}
|
|
154
|
-
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
spinner.fail(`tmux check/install failed: ${err.message}`);
|
|
143
|
+
console.log(chalk.dim(' Terminal Mirroring feature will not be available.'));
|
|
144
|
+
}
|
|
155
145
|
}
|
|
156
146
|
|
|
157
147
|
// ─── Ephemeral SSH Key Management ────────────────────────────
|
|
@@ -188,21 +178,6 @@ async function removePublicKey(authKeysPath, pubKey) {
|
|
|
188
178
|
}
|
|
189
179
|
}
|
|
190
180
|
|
|
191
|
-
/**
|
|
192
|
-
* Ping the broker to see if it's online.
|
|
193
|
-
*/
|
|
194
|
-
async function pingBroker(url) {
|
|
195
|
-
try {
|
|
196
|
-
const controller = new AbortController();
|
|
197
|
-
const id = setTimeout(() => controller.abort(), 3000);
|
|
198
|
-
const res = await fetch(`${url}/health`, { signal: controller.signal });
|
|
199
|
-
clearTimeout(id);
|
|
200
|
-
return res.ok;
|
|
201
|
-
} catch {
|
|
202
|
-
return false;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
181
|
/**
|
|
207
182
|
* Auto-spawn a Private Broker locally and wrap it in a Cloudflare tunnel.
|
|
208
183
|
*/
|
|
@@ -212,19 +187,32 @@ async function spawnPrivateBroker() {
|
|
|
212
187
|
// 1. Spawn the broker server process
|
|
213
188
|
const brokerProcess = execa('node', [path.join(__dirname, '../server.js')], {
|
|
214
189
|
env: { ...process.env, PORT: '4040' },
|
|
215
|
-
reject: false
|
|
190
|
+
reject: false,
|
|
191
|
+
all: true,
|
|
216
192
|
});
|
|
217
193
|
trackPID(brokerProcess.pid);
|
|
218
194
|
|
|
195
|
+
let brokerExited = false;
|
|
196
|
+
let brokerOutput = '';
|
|
197
|
+
brokerProcess.all?.on('data', chunk => {
|
|
198
|
+
brokerOutput += chunk.toString();
|
|
199
|
+
});
|
|
200
|
+
brokerProcess.on('exit', () => {
|
|
201
|
+
brokerExited = true;
|
|
202
|
+
});
|
|
203
|
+
|
|
219
204
|
// 2. Wrap it in a cloudflare tunnel
|
|
220
205
|
let brokerTunnelUrl = null;
|
|
221
206
|
const privateBrokerTunnelProcess = await spawnTunnelSupervised('http://localhost:4040', (newUrl) => {
|
|
222
207
|
brokerTunnelUrl = newUrl;
|
|
223
208
|
});
|
|
224
209
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
210
|
+
await waitForValue(() => {
|
|
211
|
+
if (brokerExited) {
|
|
212
|
+
throw new Error(`Private broker exited before tunnel was ready${brokerOutput ? `: ${brokerOutput.trim()}` : ''}`);
|
|
213
|
+
}
|
|
214
|
+
return brokerTunnelUrl;
|
|
215
|
+
}, 30000, 'Private broker tunnel startup');
|
|
228
216
|
|
|
229
217
|
console.log(chalk.green(` ✅ Private Broker Active: ${chalk.bold.cyan(brokerTunnelUrl)}\n`));
|
|
230
218
|
|
|
@@ -232,53 +220,11 @@ async function spawnPrivateBroker() {
|
|
|
232
220
|
url: brokerTunnelUrl,
|
|
233
221
|
kill: () => {
|
|
234
222
|
privateBrokerTunnelProcess.kill();
|
|
235
|
-
untrackPID(brokerProcess.pid);
|
|
236
|
-
try { process.kill(brokerProcess.pid); } catch { /* ignore */ }
|
|
223
|
+
killProcessTree(brokerProcess.pid).finally(() => untrackPID(brokerProcess.pid));
|
|
237
224
|
}
|
|
238
225
|
};
|
|
239
226
|
}
|
|
240
227
|
|
|
241
|
-
/**
|
|
242
|
-
* Encrypt tunnel details and register with the Central Broker.
|
|
243
|
-
*/
|
|
244
|
-
async function registerWithBroker(uid, tunnelUrl, password, serviceConfig) {
|
|
245
|
-
const spinner = createSpinner('Encrypting session data...', cryptoSpinner).start();
|
|
246
|
-
|
|
247
|
-
try {
|
|
248
|
-
// Encrypt the JSON payload LOCALLY before sending
|
|
249
|
-
await new Promise(r => setTimeout(r, 600)); // animation effect
|
|
250
|
-
const payload = JSON.stringify({ url: tunnelUrl, ...serviceConfig });
|
|
251
|
-
const encrypted = encrypt(payload, password);
|
|
252
|
-
|
|
253
|
-
spinner.text = 'Registering with broker...';
|
|
254
|
-
|
|
255
|
-
const res = await fetch(`${BROKER_URL}/register`, {
|
|
256
|
-
method: 'POST',
|
|
257
|
-
headers: { 'Content-Type': 'application/json' },
|
|
258
|
-
body: JSON.stringify({
|
|
259
|
-
uid,
|
|
260
|
-
iv: encrypted.iv,
|
|
261
|
-
ciphertext: encrypted.ciphertext,
|
|
262
|
-
salt: encrypted.salt,
|
|
263
|
-
}),
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
if (!res.ok) {
|
|
267
|
-
const data = await res.json().catch(() => ({}));
|
|
268
|
-
throw new Error(data.error || `HTTP ${res.status}`);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
spinner.succeed(`Registered with broker ${chalk.dim(`(${BROKER_URL})`)} ${chalk.green('[E2E encrypted]')}`);
|
|
272
|
-
return true;
|
|
273
|
-
} catch (err) {
|
|
274
|
-
spinner.fail(`Broker registration failed: ${err.message}`);
|
|
275
|
-
console.error(chalk.red(` ❌ Error: ${err.message}`));
|
|
276
|
-
console.log(chalk.yellow(' ⚠️ Remote clients won\'t be able to find you without the broker.'));
|
|
277
|
-
console.log(chalk.dim(' Share the tunnel URL directly if needed.'));
|
|
278
|
-
return false;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
228
|
// Monitor active connections removed (replaced by Telemetry)
|
|
283
229
|
|
|
284
230
|
/**
|
|
@@ -351,7 +297,7 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
|
|
|
351
297
|
chatTunnelProcess = null;
|
|
352
298
|
chatServerInstance = null;
|
|
353
299
|
delete serviceConfig.chatUrl;
|
|
354
|
-
await registerWithBroker(uid, tunnelUrl, password, serviceConfig);
|
|
300
|
+
await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
|
|
355
301
|
renderDashboard();
|
|
356
302
|
}
|
|
357
303
|
});
|
|
@@ -359,13 +305,11 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
|
|
|
359
305
|
console.log(chalk.dim(' Provisioning Cloudflare tunnel for chat...'));
|
|
360
306
|
chatTunnelProcess = await spawnTunnelSupervised(`http://localhost:${chatServerInstance.port}`, async (newUrl) => {
|
|
361
307
|
serviceConfig.chatUrl = newUrl;
|
|
362
|
-
await registerWithBroker(uid, tunnelUrl, password, serviceConfig);
|
|
308
|
+
await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
|
|
363
309
|
renderDashboard();
|
|
364
310
|
});
|
|
365
311
|
|
|
366
|
-
|
|
367
|
-
await new Promise(r => setTimeout(r, 100));
|
|
368
|
-
}
|
|
312
|
+
await waitForValue(() => serviceConfig.chatUrl, 30000, 'Chat tunnel startup');
|
|
369
313
|
|
|
370
314
|
console.log(chalk.green(' ✅ Chat Room Live! Clients can now join.'));
|
|
371
315
|
await openLocalChatUI(chatServerInstance.port, password);
|
|
@@ -381,14 +325,24 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
|
|
|
381
325
|
console.log('');
|
|
382
326
|
console.log(chalk.bold.cyan(' 📺 Terminal Mirroring'));
|
|
383
327
|
console.log(chalk.dim(' ──────────────────────────────────────'));
|
|
384
|
-
console.log(chalk.dim(' Attaching to
|
|
385
|
-
console.log(chalk.dim('
|
|
328
|
+
console.log(chalk.dim(' Attaching to the tmux session created by an interactive SSH client.'));
|
|
329
|
+
console.log(chalk.dim(' Press Ctrl+b then d to detach gracefully.'));
|
|
386
330
|
console.log('');
|
|
387
331
|
|
|
388
332
|
try {
|
|
333
|
+
await execaCommand('tmux -V', { reject: true });
|
|
334
|
+
const sessionCheck = await execa('tmux', ['has-session', '-t', 'SecureLink_Session'], { reject: false });
|
|
335
|
+
if (sessionCheck.exitCode !== 0) {
|
|
336
|
+
console.log(chalk.yellow(' ⚠️ No mirrored terminal session is active yet.'));
|
|
337
|
+
console.log(chalk.dim(' A client must choose "Connect via SSH" first. SCP-only clients do not create a tmux session.'));
|
|
338
|
+
console.log(chalk.dim(' tmux is needed on the host machine only; the client does not need tmux.'));
|
|
339
|
+
return waitForAction();
|
|
340
|
+
}
|
|
389
341
|
await execaCommand('tmux attach -t SecureLink_Session -r', { stdio: 'inherit' });
|
|
390
|
-
} catch {
|
|
391
|
-
console.log(chalk.yellow(' ⚠️ Could not attach
|
|
342
|
+
} catch (err) {
|
|
343
|
+
console.log(chalk.yellow(' ⚠️ Could not attach to tmux.'));
|
|
344
|
+
console.log(chalk.dim(` ${err.message}`));
|
|
345
|
+
console.log(chalk.dim(' Terminal mirroring requires tmux on the host machine and an active interactive SSH client.'));
|
|
392
346
|
}
|
|
393
347
|
return waitForAction();
|
|
394
348
|
}
|
|
@@ -430,16 +384,17 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
|
|
|
430
384
|
}
|
|
431
385
|
|
|
432
386
|
case 'reregister':
|
|
433
|
-
await registerWithBroker(uid, tunnelUrl, password, serviceConfig);
|
|
387
|
+
await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
|
|
434
388
|
return waitForAction();
|
|
435
389
|
|
|
436
390
|
case 'terminate': {
|
|
437
391
|
const spinner = createSpinner('Terminating active SSH sessions...', networkSpinner).start();
|
|
438
392
|
try {
|
|
439
393
|
if (process.platform === 'win32') {
|
|
440
|
-
await execaCommand('
|
|
394
|
+
await execaCommand('powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name = \'sshd.exe\'\\" | Where-Object { $_.CommandLine -match \'sshd:.*@\' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"', { reject: false });
|
|
441
395
|
} else {
|
|
442
396
|
await execaCommand("pkill -f 'sshd:.*@'", { shell: true, reject: false });
|
|
397
|
+
await execaCommand('tmux kill-session -t SecureLink_Session', { reject: false });
|
|
443
398
|
}
|
|
444
399
|
spinner.succeed('All client SSH sessions terminated');
|
|
445
400
|
} catch {
|
|
@@ -449,9 +404,10 @@ async function hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProc
|
|
|
449
404
|
}
|
|
450
405
|
|
|
451
406
|
case 'exit':
|
|
452
|
-
if (tunnelProcess) tunnelProcess.kill();
|
|
453
407
|
if (chatTunnelProcess) chatTunnelProcess.kill();
|
|
454
408
|
if (global.privateBrokerInstance) global.privateBrokerInstance.kill();
|
|
409
|
+
if (tunnelProcess) tunnelProcess.kill();
|
|
410
|
+
await cleanupAll();
|
|
455
411
|
return;
|
|
456
412
|
}
|
|
457
413
|
} catch (err) {
|
|
@@ -565,6 +521,7 @@ export async function startHostMode() {
|
|
|
565
521
|
|
|
566
522
|
if (serviceType === 'ssh') {
|
|
567
523
|
await ensureSSHRunning();
|
|
524
|
+
await ensureTmuxInstalled();
|
|
568
525
|
console.log(chalk.dim(' 🔑 Generating ephemeral SSH key for passwordless entry...'));
|
|
569
526
|
try {
|
|
570
527
|
const ephemeralKey = await generateEphemeralKey();
|
|
@@ -591,7 +548,7 @@ export async function startHostMode() {
|
|
|
591
548
|
const tunnelProcess = await spawnTunnelSupervised(targetUrl, async (newUrl) => {
|
|
592
549
|
tunnelUrl = newUrl;
|
|
593
550
|
// Register or re-register with broker when tunnel is spawned/respawned
|
|
594
|
-
const registered = await registerWithBroker(uid, tunnelUrl, password, serviceConfig);
|
|
551
|
+
const registered = await registerWithBroker(BROKER_URL, uid, tunnelUrl, password, serviceConfig);
|
|
595
552
|
if (!registered) {
|
|
596
553
|
console.error(chalk.red(`\n ❌ FATAL: Could not register with broker at ${BROKER_URL}`));
|
|
597
554
|
process.exit(1);
|
|
@@ -599,11 +556,9 @@ export async function startHostMode() {
|
|
|
599
556
|
});
|
|
600
557
|
|
|
601
558
|
// Wait for the first URL to be generated before showing the dashboard
|
|
602
|
-
|
|
603
|
-
await new Promise(r => setTimeout(r, 100));
|
|
604
|
-
}
|
|
559
|
+
await waitForValue(() => tunnelUrl, 30000, 'Cloudflare tunnel startup');
|
|
605
560
|
|
|
606
561
|
setRevokeOnExit(uid, BROKER_URL);
|
|
607
562
|
|
|
608
563
|
await hostDashboard(uid, tunnelUrl, password, serviceConfig, tunnelProcess);
|
|
609
|
-
}
|
|
564
|
+
}
|
package/src/server.js
CHANGED
|
@@ -137,8 +137,13 @@ app.post('/register', strictLimiter, (req, res) => {
|
|
|
137
137
|
return res.status(400).json({ error: 'Invalid IV format (expected 32 hex chars)' });
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
if (!/^[a-f0-9]{32}$/i.test(salt)) {
|
|
141
|
+
recordViolation(req);
|
|
142
|
+
return res.status(400).json({ error: 'Invalid salt format (expected 32 hex chars)' });
|
|
143
|
+
}
|
|
144
|
+
|
|
140
145
|
// Validate ciphertext is non-empty base64
|
|
141
|
-
if (typeof ciphertext !== 'string' || ciphertext.length === 0) {
|
|
146
|
+
if (typeof ciphertext !== 'string' || ciphertext.length === 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(ciphertext)) {
|
|
142
147
|
recordViolation(req);
|
|
143
148
|
return res.status(400).json({ error: 'Invalid ciphertext' });
|
|
144
149
|
}
|