From 59a41ec1d6dab0e3e65023925176235704f33375 Mon Sep 17 00:00:00 2001 From: YashIIT0909 <24je0721@iitism.ac.in> Date: Sat, 11 Apr 2026 17:37:07 +0530 Subject: [PATCH 1/4] feat: add security load testing script and npm test:load command --- package.json | 3 +- scripts/security-load-test.js | 196 ++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 1 deletion(-) create mode 100755 scripts/security-load-test.js diff --git a/package.json b/package.json index 59cf9877..26949271 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "cover:unit": "nyc --report-dir .coverage/unit npm run test:unit", "docker:build": "docker build -t nostream .", "pretest:integration": "mkdir -p .test-reports/integration", + "test:load": "node ./scripts/security-load-test.js", "test:integration": "cucumber-js", "cover:integration": "nyc --report-dir .coverage/integration npm run test:integration -- -p cover", "docker:compose:start": "./scripts/start", @@ -139,4 +140,4 @@ "path": "./node_modules/cz-conventional-changelog" } } -} +} \ No newline at end of file diff --git a/scripts/security-load-test.js b/scripts/security-load-test.js new file mode 100755 index 00000000..5aa579c9 --- /dev/null +++ b/scripts/security-load-test.js @@ -0,0 +1,196 @@ +#!/usr/bin/env node +/** + * security-load-test.js + * + * A generalized load testing and security emulation tool for Nostream. + * Simulates a combined Slowloris (Zombie) attack and an Event Flood attack. + * + * Features: + * 1. Zombie Connections: Opens connections, subscibes, and silences pongs. + * 2. Active Spammer: Generates and publishes valid NOSTR events (signed via secp256k1). + * + * Usage: + * node scripts/security-load-test.js [--url ws://localhost:8008] [--zombies 5000] [--spam-rate 100] + * + * Alternate (via npm): + * npm run test:load -- --zombies 5000 + */ + +const WebSocket = require('ws'); +const crypto = require('crypto'); +const secp256k1 = require('@noble/secp256k1'); + +// ── CLI Args ───────────────────────────────────────────────────────────────── +const args = process.argv.slice(2).reduce((acc, arg, i, arr) => { + if (arg.startsWith('--')) acc[arg.slice(2)] = arr[i + 1]; + return acc; +}, {}); + +const RELAY_URL = args.url || 'ws://localhost:8008'; +const TOTAL_ZOMBIES = parseInt(args.zombies || '5000', 10); +const SPAM_RATE = parseInt(args['spam-rate'] || '0', 10); +const BATCH_SIZE = 100; +const BATCH_DELAY_MS = 50; + +// ── State ──────────────────────────────────────────────────────────────────── +const zombies = []; +let opened = 0; +let errors = 0; +let subsSent = 0; +let spamSent = 0; + +// ── Shared Helpers ─────────────────────────────────────────────────────────── +function randomHex(bytes = 16) { + return crypto.randomBytes(bytes).toString('hex'); +} + +async function sha256(string) { + const hash = crypto.createHash('sha256').update(string).digest('hex'); + return hash; +} + +// ── Spammer Logic ──────────────────────────────────────────────────────────── +async function createValidEvent(privateKeyHex) { + const pubkey = secp256k1.utils.bytesToHex(secp256k1.schnorr.getPublicKey(privateKeyHex)); + const created_at = Math.floor(Date.now() / 1000); + const kind = 1; + const content = `Load Test Event ${created_at}-${randomHex(4)}`; + + const serialized = JSON.stringify([0, pubkey, created_at, kind, [], content]); + const id = await sha256(serialized); + const sigBytes = await secp256k1.schnorr.sign(id, privateKeyHex); + const sig = secp256k1.utils.bytesToHex(sigBytes); + + return { id, pubkey, created_at, kind, tags: [], content, sig }; +} + +function startSpammer() { + if (SPAM_RATE <= 0) return; + + const ws = new WebSocket(RELAY_URL); + const spammerPrivKey = secp256k1.utils.bytesToHex(secp256k1.utils.randomPrivateKey()); + const intervalMs = 1000 / SPAM_RATE; + + ws.on('open', () => { + console.log(`\n[SPAMMER] Connected. Flooding ${SPAM_RATE} events/sec...`); + setInterval(async () => { + const event = await createValidEvent(spammerPrivKey); + ws.send(JSON.stringify(['EVENT', event])); + spamSent++; + }, intervalMs); + }); + + ws.on('close', () => { + console.log('[SPAMMER] Disconnected. Reconnecting...'); + setTimeout(startSpammer, 1000); + }); + + ws.on('error', () => { }); +} + +// ── Zombie Logic ───────────────────────────────────────────────────────────── +function openZombie() { + return new Promise((resolve) => { + const ws = new WebSocket(RELAY_URL, { + followRedirects: false, + perMessageDeflate: false, + handshakeTimeout: 30000, + }); + + ws.on('open', () => { + opened++; + const subscriptionId = randomHex(8); + ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [1], limit: 1 }])); + subsSent++; + + // Suppress the automatic internal pong handling + if (ws._receiver) { + ws._receiver.removeAllListeners('ping'); + ws._receiver.on('ping', () => { }); + } + ws.pong = function () { }; + + zombies.push(ws); + if (opened % 500 === 0) logProgress(); + resolve(ws); + }); + + ws.on('error', (err) => { + errors++; + resolve(null); + }); + + ws.on('message', () => { }); // Discard broadcast data + }); +} + +function logProgress() { + const mem = process.memoryUsage(); + console.log( + `[ZOMBIES] Opened: ${opened}/${TOTAL_ZOMBIES} | ` + + `Client RSS: ${(mem.rss / 1024 / 1024).toFixed(1)} MB` + ); +} + +// ── Main Execution ─────────────────────────────────────────────────────────── +async function main() { + console.log('╔══════════════════════════════════════════════════════════════╗'); + console.log('║ NOSTREAM SECURITY LOAD TESTER ║'); + console.log('╠══════════════════════════════════════════════════════════════╣'); + console.log(`║ Target: ${RELAY_URL.padEnd(46)}║`); + console.log(`║ Zombies: ${String(TOTAL_ZOMBIES).padEnd(46)}║`); + console.log(`║ Spam Rate: ${String(SPAM_RATE).padEnd(41)}eps ║`); + console.log('╚══════════════════════════════════════════════════════════════╝\n'); + + // Launch Zombies + for (let i = 0; i < TOTAL_ZOMBIES; i += BATCH_SIZE) { + const batch = Math.min(BATCH_SIZE, TOTAL_ZOMBIES - i); + const promises = Array.from({ length: batch }).map(() => openZombie()); + await Promise.all(promises); + if (i + BATCH_SIZE < TOTAL_ZOMBIES) { + await new Promise(r => setTimeout(r, BATCH_DELAY_MS)); + } + } + + if (TOTAL_ZOMBIES > 0) { + console.log(`\n✅ Finished generating ${TOTAL_ZOMBIES} zombies.`); + } + + // Launch Spammer + if (SPAM_RATE > 0) { + startSpammer(); + } + + // Monitor Output + const statsInterval = setInterval(() => { + const alive = zombies.filter(ws => ws && ws.readyState === WebSocket.OPEN).length; + const closed = zombies.filter(ws => ws && ws.readyState === WebSocket.CLOSED).length; + + console.log( + `[STATS] Zombies Alive: ${alive} | Closed: ${closed} | ` + + `Spam Sent: ${spamSent}` + ); + + // Auto-exit if all zombies have been correctly evicted by the server + if (TOTAL_ZOMBIES > 0 && closed > 0 && alive === 0) { + console.log('\n✅ ALL ZOMBIES WERE EVICTED BY THE SERVER!'); + console.log(' The heartbeat memory leak fix is working correctly.'); + process.exit(0); + } + }, 15000); + + // Graceful Teardown + process.on('SIGINT', () => { + console.log('\n[SHUTDOWN] Exiting and closing connections...'); + clearInterval(statsInterval); + for (const ws of zombies) { + if (ws && ws.readyState === WebSocket.OPEN) ws.close(); + } + setTimeout(() => process.exit(0), 1000); + }); +} + +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); From 35eaa50c0b9e8a7a6658c7c7ab4e8b136df9f7f3 Mon Sep 17 00:00:00 2001 From: YashIIT0909 <24je0721@iitism.ac.in> Date: Sat, 11 Apr 2026 20:15:43 +0530 Subject: [PATCH 2/4] docs: add documentation for security and load testing procedures --- README.md | 45 +++++++++++++++++++++++++++++++++++ scripts/security-load-test.js | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3d55d3f0..beb160d8 100644 --- a/README.md +++ b/README.md @@ -570,6 +570,51 @@ To see the integration test coverage report open `.coverage/integration/lcov-rep open .coverage/integration/lcov-report/index.html ``` +## Security & Load Testing + +Nostream includes a specialized security tester to simulate Slowloris-style connection holding and event flood (spam) attacks. This is used to verify relay resilience and prevent memory leaks. + +### Running the Tester + ```bash + # Simulates 5,000 idle "zombie" connections + 100 events/sec spam + npm run test:load -- --zombies 5000 --spam-rate 100 + ``` + +### Analyzing Memory (Heap Snapshots) +To verify that connections are being correctly evicted and memory reclaimed: +1. Ensure the relay is running with `--inspect` enabled (see `docker-compose.yml`). +2. Open **Chrome DevTools** (`chrome://inspect`) and connect to the relay process. +3. In the **Memory** tab, take a **Heap Snapshot** (Baseline). +4. Run the load tester. +5. Wait for the eviction cycle (default: 120s) and take a second **Heap Snapshot**. +6. Switch the view to **Comparison** and select the Baseline snapshot. +7. Verify that object counts (e.g., `WebSocketAdapter`, `SocketAddress`) return to baseline levels. + +### Server-Side Monitoring +To observe client and subscription counts in real-time during a test, you can instrument `src/adapters/web-socket-server-adapter.ts`: + +1. Locate the `onHeartbeat()` method. +2. Add the following logging logic: + ```typescript + private onHeartbeat() { + let totalSubs = 0; + let totalClients = 0; + this.webSocketServer.clients.forEach((webSocket) => { + totalClients++; + const webSocketAdapter = this.webSocketsAdapters.get(webSocket) as IWebSocketAdapter; + if (webSocketAdapter) { + webSocketAdapter.emit(WebSocketAdapterEvent.Heartbeat); + totalSubs += webSocketAdapter.getSubscriptions().size; + } + }); + console.log(`[HEARTBEAT] Clients: ${totalClients} | Total subscriptions: ${totalSubs} | Heap Used: ${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1)} MB`); + } + ``` +3. View the live output via Docker logs: + ```bash + docker compose logs -f nostream + ``` + ## Configuration You can change the default folder by setting the `NOSTR_CONFIG_DIR` environment variable to a different path. diff --git a/scripts/security-load-test.js b/scripts/security-load-test.js index 5aa579c9..7bdc6721 100755 --- a/scripts/security-load-test.js +++ b/scripts/security-load-test.js @@ -6,7 +6,7 @@ * Simulates a combined Slowloris (Zombie) attack and an Event Flood attack. * * Features: - * 1. Zombie Connections: Opens connections, subscibes, and silences pongs. + * 1. Zombie Connections: Opens connections, subscribes, and silences pongs. * 2. Active Spammer: Generates and publishes valid NOSTR events (signed via secp256k1). * * Usage: From 84792e40fdeaaea4edcaa4b1a7f96d79d1048108 Mon Sep 17 00:00:00 2001 From: YashIIT0909 <24je0721@iitism.ac.in> Date: Sat, 11 Apr 2026 21:40:50 +0530 Subject: [PATCH 3/4] feat: enhance CLI argument parsing and spammer logic for improved error handling --- scripts/security-load-test.js | 64 ++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/scripts/security-load-test.js b/scripts/security-load-test.js index 7bdc6721..da742503 100755 --- a/scripts/security-load-test.js +++ b/scripts/security-load-test.js @@ -21,14 +21,41 @@ const crypto = require('crypto'); const secp256k1 = require('@noble/secp256k1'); // ── CLI Args ───────────────────────────────────────────────────────────────── -const args = process.argv.slice(2).reduce((acc, arg, i, arr) => { - if (arg.startsWith('--')) acc[arg.slice(2)] = arr[i + 1]; +function parseCliArgs(argv) { + const acc = {}; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (!arg.startsWith('--')) continue; + + const key = arg.slice(2); + const value = argv[i + 1]; + + if (value === undefined || value.startsWith('--')) { + console.error(`Missing value for --${key}`); + process.exit(1); + } + + acc[key] = value; + i++; + } return acc; -}, {}); +} + +function parseIntegerArg(value, defaultValue, flagName) { + if (value === undefined) return defaultValue; + const parsed = parseInt(value, 10); + if (isNaN(parsed)) { + console.error(`Invalid value for --${flagName}: ${value}. Expected an integer.`); + process.exit(1); + } + return parsed; +} + +const args = parseCliArgs(process.argv.slice(2)); const RELAY_URL = args.url || 'ws://localhost:8008'; -const TOTAL_ZOMBIES = parseInt(args.zombies || '5000', 10); -const SPAM_RATE = parseInt(args['spam-rate'] || '0', 10); +const TOTAL_ZOMBIES = parseIntegerArg(args.zombies, 5000, 'zombies'); +const SPAM_RATE = parseIntegerArg(args['spam-rate'], 0, 'spam-rate'); const BATCH_SIZE = 100; const BATCH_DELAY_MS = 50; @@ -70,22 +97,38 @@ function startSpammer() { const ws = new WebSocket(RELAY_URL); const spammerPrivKey = secp256k1.utils.bytesToHex(secp256k1.utils.randomPrivateKey()); const intervalMs = 1000 / SPAM_RATE; + let spammerInterval = null; + + function clearSpammerInterval() { + if (spammerInterval !== null) { + clearInterval(spammerInterval); + spammerInterval = null; + } + } ws.on('open', () => { console.log(`\n[SPAMMER] Connected. Flooding ${SPAM_RATE} events/sec...`); - setInterval(async () => { + clearSpammerInterval(); + spammerInterval = setInterval(async () => { + if (ws.readyState !== WebSocket.OPEN) return; + const event = await createValidEvent(spammerPrivKey); - ws.send(JSON.stringify(['EVENT', event])); - spamSent++; + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(['EVENT', event])); + spamSent++; + } }, intervalMs); }); ws.on('close', () => { + clearSpammerInterval(); console.log('[SPAMMER] Disconnected. Reconnecting...'); setTimeout(startSpammer, 1000); }); - ws.on('error', () => { }); + ws.on('error', () => { + clearSpammerInterval(); + }); } // ── Zombie Logic ───────────────────────────────────────────────────────────── @@ -107,6 +150,8 @@ function openZombie() { if (ws._receiver) { ws._receiver.removeAllListeners('ping'); ws._receiver.on('ping', () => { }); + } else { + console.warn('[ZOMBIES] Warning: ws._receiver not found. Pong suppression might fail.'); } ws.pong = function () { }; @@ -117,6 +162,7 @@ function openZombie() { ws.on('error', (err) => { errors++; + ws.terminate(); resolve(null); }); From 8d2c6f902519b1d7930b0e400ca3cb883ad19664 Mon Sep 17 00:00:00 2001 From: YashIIT0909 <24je0721@iitism.ac.in> Date: Sat, 18 Apr 2026 17:19:35 +0530 Subject: [PATCH 4/4] refactor: migrate security load test script from JavaScript to TypeScript --- package.json | 2 +- scripts/security-load-test.js | 242 ---------------------------- scripts/security-load-test.ts | 291 ++++++++++++++++++++++++++++++++++ 3 files changed, 292 insertions(+), 243 deletions(-) delete mode 100755 scripts/security-load-test.js create mode 100644 scripts/security-load-test.ts diff --git a/package.json b/package.json index 822d9458..4584085c 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "cover:unit": "nyc --report-dir .coverage/unit npm run test:unit", "docker:build": "docker build -t nostream .", "pretest:integration": "mkdir -p .test-reports/integration", - "test:load": "node ./scripts/security-load-test.js", + "test:load": "node -r ts-node/register ./scripts/security-load-test.ts", "test:integration": "cucumber-js", "cover:integration": "nyc --report-dir .coverage/integration npm run test:integration -- -p cover", "docker:compose:start": "./scripts/start", diff --git a/scripts/security-load-test.js b/scripts/security-load-test.js deleted file mode 100755 index da742503..00000000 --- a/scripts/security-load-test.js +++ /dev/null @@ -1,242 +0,0 @@ -#!/usr/bin/env node -/** - * security-load-test.js - * - * A generalized load testing and security emulation tool for Nostream. - * Simulates a combined Slowloris (Zombie) attack and an Event Flood attack. - * - * Features: - * 1. Zombie Connections: Opens connections, subscribes, and silences pongs. - * 2. Active Spammer: Generates and publishes valid NOSTR events (signed via secp256k1). - * - * Usage: - * node scripts/security-load-test.js [--url ws://localhost:8008] [--zombies 5000] [--spam-rate 100] - * - * Alternate (via npm): - * npm run test:load -- --zombies 5000 - */ - -const WebSocket = require('ws'); -const crypto = require('crypto'); -const secp256k1 = require('@noble/secp256k1'); - -// ── CLI Args ───────────────────────────────────────────────────────────────── -function parseCliArgs(argv) { - const acc = {}; - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - - const key = arg.slice(2); - const value = argv[i + 1]; - - if (value === undefined || value.startsWith('--')) { - console.error(`Missing value for --${key}`); - process.exit(1); - } - - acc[key] = value; - i++; - } - return acc; -} - -function parseIntegerArg(value, defaultValue, flagName) { - if (value === undefined) return defaultValue; - const parsed = parseInt(value, 10); - if (isNaN(parsed)) { - console.error(`Invalid value for --${flagName}: ${value}. Expected an integer.`); - process.exit(1); - } - return parsed; -} - -const args = parseCliArgs(process.argv.slice(2)); - -const RELAY_URL = args.url || 'ws://localhost:8008'; -const TOTAL_ZOMBIES = parseIntegerArg(args.zombies, 5000, 'zombies'); -const SPAM_RATE = parseIntegerArg(args['spam-rate'], 0, 'spam-rate'); -const BATCH_SIZE = 100; -const BATCH_DELAY_MS = 50; - -// ── State ──────────────────────────────────────────────────────────────────── -const zombies = []; -let opened = 0; -let errors = 0; -let subsSent = 0; -let spamSent = 0; - -// ── Shared Helpers ─────────────────────────────────────────────────────────── -function randomHex(bytes = 16) { - return crypto.randomBytes(bytes).toString('hex'); -} - -async function sha256(string) { - const hash = crypto.createHash('sha256').update(string).digest('hex'); - return hash; -} - -// ── Spammer Logic ──────────────────────────────────────────────────────────── -async function createValidEvent(privateKeyHex) { - const pubkey = secp256k1.utils.bytesToHex(secp256k1.schnorr.getPublicKey(privateKeyHex)); - const created_at = Math.floor(Date.now() / 1000); - const kind = 1; - const content = `Load Test Event ${created_at}-${randomHex(4)}`; - - const serialized = JSON.stringify([0, pubkey, created_at, kind, [], content]); - const id = await sha256(serialized); - const sigBytes = await secp256k1.schnorr.sign(id, privateKeyHex); - const sig = secp256k1.utils.bytesToHex(sigBytes); - - return { id, pubkey, created_at, kind, tags: [], content, sig }; -} - -function startSpammer() { - if (SPAM_RATE <= 0) return; - - const ws = new WebSocket(RELAY_URL); - const spammerPrivKey = secp256k1.utils.bytesToHex(secp256k1.utils.randomPrivateKey()); - const intervalMs = 1000 / SPAM_RATE; - let spammerInterval = null; - - function clearSpammerInterval() { - if (spammerInterval !== null) { - clearInterval(spammerInterval); - spammerInterval = null; - } - } - - ws.on('open', () => { - console.log(`\n[SPAMMER] Connected. Flooding ${SPAM_RATE} events/sec...`); - clearSpammerInterval(); - spammerInterval = setInterval(async () => { - if (ws.readyState !== WebSocket.OPEN) return; - - const event = await createValidEvent(spammerPrivKey); - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(['EVENT', event])); - spamSent++; - } - }, intervalMs); - }); - - ws.on('close', () => { - clearSpammerInterval(); - console.log('[SPAMMER] Disconnected. Reconnecting...'); - setTimeout(startSpammer, 1000); - }); - - ws.on('error', () => { - clearSpammerInterval(); - }); -} - -// ── Zombie Logic ───────────────────────────────────────────────────────────── -function openZombie() { - return new Promise((resolve) => { - const ws = new WebSocket(RELAY_URL, { - followRedirects: false, - perMessageDeflate: false, - handshakeTimeout: 30000, - }); - - ws.on('open', () => { - opened++; - const subscriptionId = randomHex(8); - ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [1], limit: 1 }])); - subsSent++; - - // Suppress the automatic internal pong handling - if (ws._receiver) { - ws._receiver.removeAllListeners('ping'); - ws._receiver.on('ping', () => { }); - } else { - console.warn('[ZOMBIES] Warning: ws._receiver not found. Pong suppression might fail.'); - } - ws.pong = function () { }; - - zombies.push(ws); - if (opened % 500 === 0) logProgress(); - resolve(ws); - }); - - ws.on('error', (err) => { - errors++; - ws.terminate(); - resolve(null); - }); - - ws.on('message', () => { }); // Discard broadcast data - }); -} - -function logProgress() { - const mem = process.memoryUsage(); - console.log( - `[ZOMBIES] Opened: ${opened}/${TOTAL_ZOMBIES} | ` + - `Client RSS: ${(mem.rss / 1024 / 1024).toFixed(1)} MB` - ); -} - -// ── Main Execution ─────────────────────────────────────────────────────────── -async function main() { - console.log('╔══════════════════════════════════════════════════════════════╗'); - console.log('║ NOSTREAM SECURITY LOAD TESTER ║'); - console.log('╠══════════════════════════════════════════════════════════════╣'); - console.log(`║ Target: ${RELAY_URL.padEnd(46)}║`); - console.log(`║ Zombies: ${String(TOTAL_ZOMBIES).padEnd(46)}║`); - console.log(`║ Spam Rate: ${String(SPAM_RATE).padEnd(41)}eps ║`); - console.log('╚══════════════════════════════════════════════════════════════╝\n'); - - // Launch Zombies - for (let i = 0; i < TOTAL_ZOMBIES; i += BATCH_SIZE) { - const batch = Math.min(BATCH_SIZE, TOTAL_ZOMBIES - i); - const promises = Array.from({ length: batch }).map(() => openZombie()); - await Promise.all(promises); - if (i + BATCH_SIZE < TOTAL_ZOMBIES) { - await new Promise(r => setTimeout(r, BATCH_DELAY_MS)); - } - } - - if (TOTAL_ZOMBIES > 0) { - console.log(`\n✅ Finished generating ${TOTAL_ZOMBIES} zombies.`); - } - - // Launch Spammer - if (SPAM_RATE > 0) { - startSpammer(); - } - - // Monitor Output - const statsInterval = setInterval(() => { - const alive = zombies.filter(ws => ws && ws.readyState === WebSocket.OPEN).length; - const closed = zombies.filter(ws => ws && ws.readyState === WebSocket.CLOSED).length; - - console.log( - `[STATS] Zombies Alive: ${alive} | Closed: ${closed} | ` + - `Spam Sent: ${spamSent}` - ); - - // Auto-exit if all zombies have been correctly evicted by the server - if (TOTAL_ZOMBIES > 0 && closed > 0 && alive === 0) { - console.log('\n✅ ALL ZOMBIES WERE EVICTED BY THE SERVER!'); - console.log(' The heartbeat memory leak fix is working correctly.'); - process.exit(0); - } - }, 15000); - - // Graceful Teardown - process.on('SIGINT', () => { - console.log('\n[SHUTDOWN] Exiting and closing connections...'); - clearInterval(statsInterval); - for (const ws of zombies) { - if (ws && ws.readyState === WebSocket.OPEN) ws.close(); - } - setTimeout(() => process.exit(0), 1000); - }); -} - -main().catch((err) => { - console.error('Fatal error:', err); - process.exit(1); -}); diff --git a/scripts/security-load-test.ts b/scripts/security-load-test.ts new file mode 100644 index 00000000..db8bd5ed --- /dev/null +++ b/scripts/security-load-test.ts @@ -0,0 +1,291 @@ +#!/usr/bin/env node +/** + * security-load-test.ts + * + * A generalized load testing and security emulation tool for Nostream. + * Simulates a combined Slowloris (Zombie) attack and an Event Flood attack. + * + * Features: + * 1. Zombie Connections: Opens connections, subscribes, and silences pongs. + * 2. Active Spammer: Generates and publishes valid NOSTR events (signed via secp256k1). + * + * Usage: + * npx ts-node scripts/security-load-test.ts [--url ws://localhost:8008] [--zombies 5000] [--spam-rate 100] + * + * Alternate (via npm): + * npm run test:load -- --zombies 5000 + */ + +import WebSocket from 'ws' +import * as crypto from 'crypto' +import * as secp256k1 from '@noble/secp256k1' + +// ── Types ───────────────────────────────────────────────────────────────────── + +/** Parsed key-value map from CLI --flag value pairs. */ +type CliArgs = Record + +/** A valid serialised Nostr event (NIP-01). */ +interface NostrEvent { + id: string + pubkey: string + created_at: number + kind: number + tags: string[][] + content: string + sig: string +} + +/** + * The `ws` package exposes a private `_receiver` property on WebSocket + * instances that is used internally for frame parsing and ping/pong handling. + * We cast to this interface to suppress pong responses in zombie connections. + */ +interface WebSocketWithReceiver extends WebSocket { + _receiver?: { + removeAllListeners(event: string): void + on(event: string, listener: () => void): void + } + /** Override the built-in pong helper to become a no-op. */ + pong: (...args: unknown[]) => void +} + +// ── CLI Args ───────────────────────────────────────────────────────────────── + +function parseCliArgs(argv: string[]): CliArgs { + const acc: CliArgs = {} + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (!arg.startsWith('--')) continue + + const key: string = arg.slice(2) + const value: string | undefined = argv[i + 1] + + if (value === undefined || value.startsWith('--')) { + console.error(`Missing value for --${key}`) + process.exit(1) + } + + acc[key] = value + i++ + } + return acc +} + +function parseIntegerArg( + value: string | undefined, + defaultValue: number, + flagName: string, +): number { + if (value === undefined) return defaultValue + const parsed = parseInt(value, 10) + if (isNaN(parsed)) { + console.error(`Invalid value for --${flagName}: ${value}. Expected an integer.`) + process.exit(1) + } + return parsed +} + +const args: CliArgs = parseCliArgs(process.argv.slice(2)) + +const RELAY_URL: string = args.url ?? 'ws://localhost:8008' +const TOTAL_ZOMBIES: number = parseIntegerArg(args.zombies, 5000, 'zombies') +const SPAM_RATE: number = parseIntegerArg(args['spam-rate'], 0, 'spam-rate') +const BATCH_SIZE: number = 100 +const BATCH_DELAY_MS: number = 50 + +// ── State ──────────────────────────────────────────────────────────────────── + +const zombies: WebSocketWithReceiver[] = [] +let opened: number = 0 +let errors: number = 0 +let subsSent: number = 0 +let spamSent: number = 0 + +// ── Shared Helpers ─────────────────────────────────────────────────────────── + +function randomHex(bytes: number = 16): string { + return crypto.randomBytes(bytes).toString('hex') +} + +async function sha256(input: string): Promise { + return crypto.createHash('sha256').update(input).digest('hex') +} + +// ── Spammer Logic ──────────────────────────────────────────────────────────── + +async function createValidEvent(privateKeyHex: string): Promise { + const pubkey: string = secp256k1.utils.bytesToHex( + secp256k1.schnorr.getPublicKey(privateKeyHex), + ) + const created_at: number = Math.floor(Date.now() / 1000) + const kind: number = 1 + const content: string = `Load Test Event ${created_at}-${randomHex(4)}` + + const serialized: string = JSON.stringify([0, pubkey, created_at, kind, [], content]) + const id: string = await sha256(serialized) + const sigBytes: Uint8Array = await secp256k1.schnorr.sign(id, privateKeyHex) + const sig: string = secp256k1.utils.bytesToHex(sigBytes) + + return { id, pubkey, created_at, kind, tags: [], content, sig } +} + +function startSpammer(): void { + if (SPAM_RATE <= 0) return + + const ws = new WebSocket(RELAY_URL) + const spammerPrivKey: string = secp256k1.utils.bytesToHex( + secp256k1.utils.randomPrivateKey(), + ) + const intervalMs: number = 1000 / SPAM_RATE + let spammerInterval: ReturnType | null = null + + function clearSpammerInterval(): void { + if (spammerInterval !== null) { + clearInterval(spammerInterval) + spammerInterval = null + } + } + + ws.on('open', () => { + console.log(`\n[SPAMMER] Connected. Flooding ${SPAM_RATE} events/sec...`) + clearSpammerInterval() + spammerInterval = setInterval(async () => { + if (ws.readyState !== WebSocket.OPEN) return + + const event: NostrEvent = await createValidEvent(spammerPrivKey) + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(['EVENT', event])) + spamSent++ + } + }, intervalMs) + }) + + ws.on('close', () => { + clearSpammerInterval() + console.log('[SPAMMER] Disconnected. Reconnecting...') + setTimeout(startSpammer, 1000) + }) + + ws.on('error', () => { + clearSpammerInterval() + }) +} + +// ── Zombie Logic ───────────────────────────────────────────────────────────── + +function openZombie(): Promise { + return new Promise((resolve) => { + const ws = new WebSocket(RELAY_URL, { + followRedirects: false, + perMessageDeflate: false, + handshakeTimeout: 30000, + }) as WebSocketWithReceiver + + ws.on('open', () => { + opened++ + const subscriptionId: string = randomHex(8) + ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [1], limit: 1 }])) + subsSent++ + + // Suppress the automatic internal pong handling + if (ws._receiver) { + ws._receiver.removeAllListeners('ping') + ws._receiver.on('ping', () => { }) + } else { + console.warn('[ZOMBIES] Warning: ws._receiver not found. Pong suppression might fail.') + } + ws.pong = function (): void { } + + zombies.push(ws) + if (opened % 500 === 0) logProgress() + resolve(ws) + }) + + ws.on('error', (_err: Error) => { + errors++ + ws.terminate() + resolve(null) + }) + + ws.on('message', () => { }) // Discard broadcast data + }) +} + +function logProgress(): void { + const mem: NodeJS.MemoryUsage = process.memoryUsage() + console.log( + `[ZOMBIES] Opened: ${opened}/${TOTAL_ZOMBIES} | ` + + `Client RSS: ${(mem.rss / 1024 / 1024).toFixed(1)} MB`, + ) +} + +// ── Main Execution ─────────────────────────────────────────────────────────── + +async function main(): Promise { + console.log('╔══════════════════════════════════════════════════════════════╗') + console.log('║ NOSTREAM SECURITY LOAD TESTER ║') + console.log('╠══════════════════════════════════════════════════════════════╣') + console.log(`║ Target: ${RELAY_URL.padEnd(46)}║`) + console.log(`║ Zombies: ${String(TOTAL_ZOMBIES).padEnd(46)}║`) + console.log(`║ Spam Rate: ${String(SPAM_RATE).padEnd(41)}eps ║`) + console.log('╚══════════════════════════════════════════════════════════════╝\n') + + // Launch Zombies + for (let i = 0; i < TOTAL_ZOMBIES; i += BATCH_SIZE) { + const batch: number = Math.min(BATCH_SIZE, TOTAL_ZOMBIES - i) + const promises: Promise[] = Array.from({ length: batch }).map( + () => openZombie(), + ) + await Promise.all(promises) + if (i + BATCH_SIZE < TOTAL_ZOMBIES) { + await new Promise((r) => setTimeout(r, BATCH_DELAY_MS)) + } + } + + if (TOTAL_ZOMBIES > 0) { + console.log(`\n✅ Finished generating ${TOTAL_ZOMBIES} zombies.`) + } + + // Launch Spammer + if (SPAM_RATE > 0) { + startSpammer() + } + + // Monitor Output + const statsInterval: ReturnType = setInterval(() => { + const alive: number = zombies.filter( + (ws) => ws && ws.readyState === WebSocket.OPEN, + ).length + const closed: number = zombies.filter( + (ws) => ws && ws.readyState === WebSocket.CLOSED, + ).length + + console.log( + `[STATS] Zombies Alive: ${alive} | Closed: ${closed} | ` + + `Spam Sent: ${spamSent}`, + ) + + // Auto-exit if all zombies have been correctly evicted by the server + if (TOTAL_ZOMBIES > 0 && closed > 0 && alive === 0) { + console.log('\n✅ ALL ZOMBIES WERE EVICTED BY THE SERVER!') + console.log(' The heartbeat memory leak fix is working correctly.') + process.exit(0) + } + }, 15000) + + // Graceful Teardown + process.on('SIGINT', () => { + console.log('\n[SHUTDOWN] Exiting and closing connections...') + clearInterval(statsInterval) + for (const ws of zombies) { + if (ws && ws.readyState === WebSocket.OPEN) ws.close() + } + setTimeout(() => process.exit(0), 1000) + }) +} + +main().catch((err: unknown) => { + console.error('Fatal error:', err) + process.exit(1) +})