Skip to content

Commit 59a41ec

Browse files
committed
feat: add security load testing script and npm test:load command
1 parent 4652d00 commit 59a41ec

2 files changed

Lines changed: 198 additions & 1 deletion

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"cover:unit": "nyc --report-dir .coverage/unit npm run test:unit",
4141
"docker:build": "docker build -t nostream .",
4242
"pretest:integration": "mkdir -p .test-reports/integration",
43+
"test:load": "node ./scripts/security-load-test.js",
4344
"test:integration": "cucumber-js",
4445
"cover:integration": "nyc --report-dir .coverage/integration npm run test:integration -- -p cover",
4546
"docker:compose:start": "./scripts/start",
@@ -139,4 +140,4 @@
139140
"path": "./node_modules/cz-conventional-changelog"
140141
}
141142
}
142-
}
143+
}

scripts/security-load-test.js

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
#!/usr/bin/env node
2+
/**
3+
* security-load-test.js
4+
*
5+
* A generalized load testing and security emulation tool for Nostream.
6+
* Simulates a combined Slowloris (Zombie) attack and an Event Flood attack.
7+
*
8+
* Features:
9+
* 1. Zombie Connections: Opens connections, subscibes, and silences pongs.
10+
* 2. Active Spammer: Generates and publishes valid NOSTR events (signed via secp256k1).
11+
*
12+
* Usage:
13+
* node scripts/security-load-test.js [--url ws://localhost:8008] [--zombies 5000] [--spam-rate 100]
14+
*
15+
* Alternate (via npm):
16+
* npm run test:load -- --zombies 5000
17+
*/
18+
19+
const WebSocket = require('ws');
20+
const crypto = require('crypto');
21+
const secp256k1 = require('@noble/secp256k1');
22+
23+
// ── CLI Args ─────────────────────────────────────────────────────────────────
24+
const args = process.argv.slice(2).reduce((acc, arg, i, arr) => {
25+
if (arg.startsWith('--')) acc[arg.slice(2)] = arr[i + 1];
26+
return acc;
27+
}, {});
28+
29+
const RELAY_URL = args.url || 'ws://localhost:8008';
30+
const TOTAL_ZOMBIES = parseInt(args.zombies || '5000', 10);
31+
const SPAM_RATE = parseInt(args['spam-rate'] || '0', 10);
32+
const BATCH_SIZE = 100;
33+
const BATCH_DELAY_MS = 50;
34+
35+
// ── State ────────────────────────────────────────────────────────────────────
36+
const zombies = [];
37+
let opened = 0;
38+
let errors = 0;
39+
let subsSent = 0;
40+
let spamSent = 0;
41+
42+
// ── Shared Helpers ───────────────────────────────────────────────────────────
43+
function randomHex(bytes = 16) {
44+
return crypto.randomBytes(bytes).toString('hex');
45+
}
46+
47+
async function sha256(string) {
48+
const hash = crypto.createHash('sha256').update(string).digest('hex');
49+
return hash;
50+
}
51+
52+
// ── Spammer Logic ────────────────────────────────────────────────────────────
53+
async function createValidEvent(privateKeyHex) {
54+
const pubkey = secp256k1.utils.bytesToHex(secp256k1.schnorr.getPublicKey(privateKeyHex));
55+
const created_at = Math.floor(Date.now() / 1000);
56+
const kind = 1;
57+
const content = `Load Test Event ${created_at}-${randomHex(4)}`;
58+
59+
const serialized = JSON.stringify([0, pubkey, created_at, kind, [], content]);
60+
const id = await sha256(serialized);
61+
const sigBytes = await secp256k1.schnorr.sign(id, privateKeyHex);
62+
const sig = secp256k1.utils.bytesToHex(sigBytes);
63+
64+
return { id, pubkey, created_at, kind, tags: [], content, sig };
65+
}
66+
67+
function startSpammer() {
68+
if (SPAM_RATE <= 0) return;
69+
70+
const ws = new WebSocket(RELAY_URL);
71+
const spammerPrivKey = secp256k1.utils.bytesToHex(secp256k1.utils.randomPrivateKey());
72+
const intervalMs = 1000 / SPAM_RATE;
73+
74+
ws.on('open', () => {
75+
console.log(`\n[SPAMMER] Connected. Flooding ${SPAM_RATE} events/sec...`);
76+
setInterval(async () => {
77+
const event = await createValidEvent(spammerPrivKey);
78+
ws.send(JSON.stringify(['EVENT', event]));
79+
spamSent++;
80+
}, intervalMs);
81+
});
82+
83+
ws.on('close', () => {
84+
console.log('[SPAMMER] Disconnected. Reconnecting...');
85+
setTimeout(startSpammer, 1000);
86+
});
87+
88+
ws.on('error', () => { });
89+
}
90+
91+
// ── Zombie Logic ─────────────────────────────────────────────────────────────
92+
function openZombie() {
93+
return new Promise((resolve) => {
94+
const ws = new WebSocket(RELAY_URL, {
95+
followRedirects: false,
96+
perMessageDeflate: false,
97+
handshakeTimeout: 30000,
98+
});
99+
100+
ws.on('open', () => {
101+
opened++;
102+
const subscriptionId = randomHex(8);
103+
ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [1], limit: 1 }]));
104+
subsSent++;
105+
106+
// Suppress the automatic internal pong handling
107+
if (ws._receiver) {
108+
ws._receiver.removeAllListeners('ping');
109+
ws._receiver.on('ping', () => { });
110+
}
111+
ws.pong = function () { };
112+
113+
zombies.push(ws);
114+
if (opened % 500 === 0) logProgress();
115+
resolve(ws);
116+
});
117+
118+
ws.on('error', (err) => {
119+
errors++;
120+
resolve(null);
121+
});
122+
123+
ws.on('message', () => { }); // Discard broadcast data
124+
});
125+
}
126+
127+
function logProgress() {
128+
const mem = process.memoryUsage();
129+
console.log(
130+
`[ZOMBIES] Opened: ${opened}/${TOTAL_ZOMBIES} | ` +
131+
`Client RSS: ${(mem.rss / 1024 / 1024).toFixed(1)} MB`
132+
);
133+
}
134+
135+
// ── Main Execution ───────────────────────────────────────────────────────────
136+
async function main() {
137+
console.log('╔══════════════════════════════════════════════════════════════╗');
138+
console.log('║ NOSTREAM SECURITY LOAD TESTER ║');
139+
console.log('╠══════════════════════════════════════════════════════════════╣');
140+
console.log(`║ Target: ${RELAY_URL.padEnd(46)}║`);
141+
console.log(`║ Zombies: ${String(TOTAL_ZOMBIES).padEnd(46)}║`);
142+
console.log(`║ Spam Rate: ${String(SPAM_RATE).padEnd(41)}eps ║`);
143+
console.log('╚══════════════════════════════════════════════════════════════╝\n');
144+
145+
// Launch Zombies
146+
for (let i = 0; i < TOTAL_ZOMBIES; i += BATCH_SIZE) {
147+
const batch = Math.min(BATCH_SIZE, TOTAL_ZOMBIES - i);
148+
const promises = Array.from({ length: batch }).map(() => openZombie());
149+
await Promise.all(promises);
150+
if (i + BATCH_SIZE < TOTAL_ZOMBIES) {
151+
await new Promise(r => setTimeout(r, BATCH_DELAY_MS));
152+
}
153+
}
154+
155+
if (TOTAL_ZOMBIES > 0) {
156+
console.log(`\n✅ Finished generating ${TOTAL_ZOMBIES} zombies.`);
157+
}
158+
159+
// Launch Spammer
160+
if (SPAM_RATE > 0) {
161+
startSpammer();
162+
}
163+
164+
// Monitor Output
165+
const statsInterval = setInterval(() => {
166+
const alive = zombies.filter(ws => ws && ws.readyState === WebSocket.OPEN).length;
167+
const closed = zombies.filter(ws => ws && ws.readyState === WebSocket.CLOSED).length;
168+
169+
console.log(
170+
`[STATS] Zombies Alive: ${alive} | Closed: ${closed} | ` +
171+
`Spam Sent: ${spamSent}`
172+
);
173+
174+
// Auto-exit if all zombies have been correctly evicted by the server
175+
if (TOTAL_ZOMBIES > 0 && closed > 0 && alive === 0) {
176+
console.log('\n✅ ALL ZOMBIES WERE EVICTED BY THE SERVER!');
177+
console.log(' The heartbeat memory leak fix is working correctly.');
178+
process.exit(0);
179+
}
180+
}, 15000);
181+
182+
// Graceful Teardown
183+
process.on('SIGINT', () => {
184+
console.log('\n[SHUTDOWN] Exiting and closing connections...');
185+
clearInterval(statsInterval);
186+
for (const ws of zombies) {
187+
if (ws && ws.readyState === WebSocket.OPEN) ws.close();
188+
}
189+
setTimeout(() => process.exit(0), 1000);
190+
});
191+
}
192+
193+
main().catch((err) => {
194+
console.error('Fatal error:', err);
195+
process.exit(1);
196+
});

0 commit comments

Comments
 (0)