|
| 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