|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * security-load-test.ts |
| 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, subscribes, and silences pongs. |
| 10 | + * 2. Active Spammer: Generates and publishes valid NOSTR events (signed via secp256k1). |
| 11 | + * |
| 12 | + * Usage: |
| 13 | + * npx ts-node scripts/security-load-test.ts [--url ws://localhost:8008] [--zombies 5000] [--spam-rate 100] |
| 14 | + * |
| 15 | + * Alternate (via npm): |
| 16 | + * npm run test:load -- --zombies 5000 |
| 17 | + */ |
| 18 | + |
| 19 | +import WebSocket from 'ws' |
| 20 | +import * as crypto from 'crypto' |
| 21 | +import * as secp256k1 from '@noble/secp256k1' |
| 22 | + |
| 23 | +// ── Types ───────────────────────────────────────────────────────────────────── |
| 24 | + |
| 25 | +/** Parsed key-value map from CLI --flag value pairs. */ |
| 26 | +type CliArgs = Record<string, string> |
| 27 | + |
| 28 | +/** A valid serialised Nostr event (NIP-01). */ |
| 29 | +interface NostrEvent { |
| 30 | + id: string |
| 31 | + pubkey: string |
| 32 | + created_at: number |
| 33 | + kind: number |
| 34 | + tags: string[][] |
| 35 | + content: string |
| 36 | + sig: string |
| 37 | +} |
| 38 | + |
| 39 | +/** |
| 40 | + * The `ws` package exposes a private `_receiver` property on WebSocket |
| 41 | + * instances that is used internally for frame parsing and ping/pong handling. |
| 42 | + * We cast to this interface to suppress pong responses in zombie connections. |
| 43 | + */ |
| 44 | +interface WebSocketWithReceiver extends WebSocket { |
| 45 | + _receiver?: { |
| 46 | + removeAllListeners(event: string): void |
| 47 | + on(event: string, listener: () => void): void |
| 48 | + } |
| 49 | + /** Override the built-in pong helper to become a no-op. */ |
| 50 | + pong: (...args: unknown[]) => void |
| 51 | +} |
| 52 | + |
| 53 | +// ── CLI Args ───────────────────────────────────────────────────────────────── |
| 54 | + |
| 55 | +function parseCliArgs(argv: string[]): CliArgs { |
| 56 | + const acc: CliArgs = {} |
| 57 | + for (let i = 0; i < argv.length; i++) { |
| 58 | + const arg = argv[i] |
| 59 | + if (!arg.startsWith('--')) continue |
| 60 | + |
| 61 | + const key: string = arg.slice(2) |
| 62 | + const value: string | undefined = argv[i + 1] |
| 63 | + |
| 64 | + if (value === undefined || value.startsWith('--')) { |
| 65 | + console.error(`Missing value for --${key}`) |
| 66 | + process.exit(1) |
| 67 | + } |
| 68 | + |
| 69 | + acc[key] = value |
| 70 | + i++ |
| 71 | + } |
| 72 | + return acc |
| 73 | +} |
| 74 | + |
| 75 | +function parseIntegerArg( |
| 76 | + value: string | undefined, |
| 77 | + defaultValue: number, |
| 78 | + flagName: string, |
| 79 | +): number { |
| 80 | + if (value === undefined) return defaultValue |
| 81 | + const parsed = parseInt(value, 10) |
| 82 | + if (isNaN(parsed)) { |
| 83 | + console.error(`Invalid value for --${flagName}: ${value}. Expected an integer.`) |
| 84 | + process.exit(1) |
| 85 | + } |
| 86 | + return parsed |
| 87 | +} |
| 88 | + |
| 89 | +const args: CliArgs = parseCliArgs(process.argv.slice(2)) |
| 90 | + |
| 91 | +const RELAY_URL: string = args.url ?? 'ws://localhost:8008' |
| 92 | +const TOTAL_ZOMBIES: number = parseIntegerArg(args.zombies, 5000, 'zombies') |
| 93 | +const SPAM_RATE: number = parseIntegerArg(args['spam-rate'], 0, 'spam-rate') |
| 94 | +const BATCH_SIZE: number = 100 |
| 95 | +const BATCH_DELAY_MS: number = 50 |
| 96 | + |
| 97 | +// ── State ──────────────────────────────────────────────────────────────────── |
| 98 | + |
| 99 | +const zombies: WebSocketWithReceiver[] = [] |
| 100 | +let opened: number = 0 |
| 101 | +let errors: number = 0 |
| 102 | +let subsSent: number = 0 |
| 103 | +let spamSent: number = 0 |
| 104 | + |
| 105 | +// ── Shared Helpers ─────────────────────────────────────────────────────────── |
| 106 | + |
| 107 | +function randomHex(bytes: number = 16): string { |
| 108 | + return crypto.randomBytes(bytes).toString('hex') |
| 109 | +} |
| 110 | + |
| 111 | +async function sha256(input: string): Promise<string> { |
| 112 | + return crypto.createHash('sha256').update(input).digest('hex') |
| 113 | +} |
| 114 | + |
| 115 | +// ── Spammer Logic ──────────────────────────────────────────────────────────── |
| 116 | + |
| 117 | +async function createValidEvent(privateKeyHex: string): Promise<NostrEvent> { |
| 118 | + const pubkey: string = secp256k1.utils.bytesToHex( |
| 119 | + secp256k1.schnorr.getPublicKey(privateKeyHex), |
| 120 | + ) |
| 121 | + const created_at: number = Math.floor(Date.now() / 1000) |
| 122 | + const kind: number = 1 |
| 123 | + const content: string = `Load Test Event ${created_at}-${randomHex(4)}` |
| 124 | + |
| 125 | + const serialized: string = JSON.stringify([0, pubkey, created_at, kind, [], content]) |
| 126 | + const id: string = await sha256(serialized) |
| 127 | + const sigBytes: Uint8Array = await secp256k1.schnorr.sign(id, privateKeyHex) |
| 128 | + const sig: string = secp256k1.utils.bytesToHex(sigBytes) |
| 129 | + |
| 130 | + return { id, pubkey, created_at, kind, tags: [], content, sig } |
| 131 | +} |
| 132 | + |
| 133 | +function startSpammer(): void { |
| 134 | + if (SPAM_RATE <= 0) return |
| 135 | + |
| 136 | + const ws = new WebSocket(RELAY_URL) |
| 137 | + const spammerPrivKey: string = secp256k1.utils.bytesToHex( |
| 138 | + secp256k1.utils.randomPrivateKey(), |
| 139 | + ) |
| 140 | + const intervalMs: number = 1000 / SPAM_RATE |
| 141 | + let spammerInterval: ReturnType<typeof setInterval> | null = null |
| 142 | + |
| 143 | + function clearSpammerInterval(): void { |
| 144 | + if (spammerInterval !== null) { |
| 145 | + clearInterval(spammerInterval) |
| 146 | + spammerInterval = null |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + ws.on('open', () => { |
| 151 | + console.log(`\n[SPAMMER] Connected. Flooding ${SPAM_RATE} events/sec...`) |
| 152 | + clearSpammerInterval() |
| 153 | + spammerInterval = setInterval(async () => { |
| 154 | + if (ws.readyState !== WebSocket.OPEN) return |
| 155 | + |
| 156 | + const event: NostrEvent = await createValidEvent(spammerPrivKey) |
| 157 | + if (ws.readyState === WebSocket.OPEN) { |
| 158 | + ws.send(JSON.stringify(['EVENT', event])) |
| 159 | + spamSent++ |
| 160 | + } |
| 161 | + }, intervalMs) |
| 162 | + }) |
| 163 | + |
| 164 | + ws.on('close', () => { |
| 165 | + clearSpammerInterval() |
| 166 | + console.log('[SPAMMER] Disconnected. Reconnecting...') |
| 167 | + setTimeout(startSpammer, 1000) |
| 168 | + }) |
| 169 | + |
| 170 | + ws.on('error', () => { |
| 171 | + clearSpammerInterval() |
| 172 | + }) |
| 173 | +} |
| 174 | + |
| 175 | +// ── Zombie Logic ───────────────────────────────────────────────────────────── |
| 176 | + |
| 177 | +function openZombie(): Promise<WebSocketWithReceiver | null> { |
| 178 | + return new Promise((resolve) => { |
| 179 | + const ws = new WebSocket(RELAY_URL, { |
| 180 | + followRedirects: false, |
| 181 | + perMessageDeflate: false, |
| 182 | + handshakeTimeout: 30000, |
| 183 | + }) as WebSocketWithReceiver |
| 184 | + |
| 185 | + ws.on('open', () => { |
| 186 | + opened++ |
| 187 | + const subscriptionId: string = randomHex(8) |
| 188 | + ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [1], limit: 1 }])) |
| 189 | + subsSent++ |
| 190 | + |
| 191 | + // Suppress the automatic internal pong handling |
| 192 | + if (ws._receiver) { |
| 193 | + ws._receiver.removeAllListeners('ping') |
| 194 | + ws._receiver.on('ping', () => { }) |
| 195 | + } else { |
| 196 | + console.warn('[ZOMBIES] Warning: ws._receiver not found. Pong suppression might fail.') |
| 197 | + } |
| 198 | + ws.pong = function (): void { } |
| 199 | + |
| 200 | + zombies.push(ws) |
| 201 | + if (opened % 500 === 0) logProgress() |
| 202 | + resolve(ws) |
| 203 | + }) |
| 204 | + |
| 205 | + ws.on('error', (_err: Error) => { |
| 206 | + errors++ |
| 207 | + ws.terminate() |
| 208 | + resolve(null) |
| 209 | + }) |
| 210 | + |
| 211 | + ws.on('message', () => { }) // Discard broadcast data |
| 212 | + }) |
| 213 | +} |
| 214 | + |
| 215 | +function logProgress(): void { |
| 216 | + const mem: NodeJS.MemoryUsage = process.memoryUsage() |
| 217 | + console.log( |
| 218 | + `[ZOMBIES] Opened: ${opened}/${TOTAL_ZOMBIES} | ` + |
| 219 | + `Client RSS: ${(mem.rss / 1024 / 1024).toFixed(1)} MB`, |
| 220 | + ) |
| 221 | +} |
| 222 | + |
| 223 | +// ── Main Execution ─────────────────────────────────────────────────────────── |
| 224 | + |
| 225 | +async function main(): Promise<void> { |
| 226 | + console.log('╔══════════════════════════════════════════════════════════════╗') |
| 227 | + console.log('║ NOSTREAM SECURITY LOAD TESTER ║') |
| 228 | + console.log('╠══════════════════════════════════════════════════════════════╣') |
| 229 | + console.log(`║ Target: ${RELAY_URL.padEnd(46)}║`) |
| 230 | + console.log(`║ Zombies: ${String(TOTAL_ZOMBIES).padEnd(46)}║`) |
| 231 | + console.log(`║ Spam Rate: ${String(SPAM_RATE).padEnd(41)}eps ║`) |
| 232 | + console.log('╚══════════════════════════════════════════════════════════════╝\n') |
| 233 | + |
| 234 | + // Launch Zombies |
| 235 | + for (let i = 0; i < TOTAL_ZOMBIES; i += BATCH_SIZE) { |
| 236 | + const batch: number = Math.min(BATCH_SIZE, TOTAL_ZOMBIES - i) |
| 237 | + const promises: Promise<WebSocketWithReceiver | null>[] = Array.from({ length: batch }).map( |
| 238 | + () => openZombie(), |
| 239 | + ) |
| 240 | + await Promise.all(promises) |
| 241 | + if (i + BATCH_SIZE < TOTAL_ZOMBIES) { |
| 242 | + await new Promise<void>((r) => setTimeout(r, BATCH_DELAY_MS)) |
| 243 | + } |
| 244 | + } |
| 245 | + |
| 246 | + if (TOTAL_ZOMBIES > 0) { |
| 247 | + console.log(`\n✅ Finished generating ${TOTAL_ZOMBIES} zombies.`) |
| 248 | + } |
| 249 | + |
| 250 | + // Launch Spammer |
| 251 | + if (SPAM_RATE > 0) { |
| 252 | + startSpammer() |
| 253 | + } |
| 254 | + |
| 255 | + // Monitor Output |
| 256 | + const statsInterval: ReturnType<typeof setInterval> = setInterval(() => { |
| 257 | + const alive: number = zombies.filter( |
| 258 | + (ws) => ws && ws.readyState === WebSocket.OPEN, |
| 259 | + ).length |
| 260 | + const closed: number = zombies.filter( |
| 261 | + (ws) => ws && ws.readyState === WebSocket.CLOSED, |
| 262 | + ).length |
| 263 | + |
| 264 | + console.log( |
| 265 | + `[STATS] Zombies Alive: ${alive} | Closed: ${closed} | ` + |
| 266 | + `Spam Sent: ${spamSent}`, |
| 267 | + ) |
| 268 | + |
| 269 | + // Auto-exit if all zombies have been correctly evicted by the server |
| 270 | + if (TOTAL_ZOMBIES > 0 && closed > 0 && alive === 0) { |
| 271 | + console.log('\n✅ ALL ZOMBIES WERE EVICTED BY THE SERVER!') |
| 272 | + console.log(' The heartbeat memory leak fix is working correctly.') |
| 273 | + process.exit(0) |
| 274 | + } |
| 275 | + }, 15000) |
| 276 | + |
| 277 | + // Graceful Teardown |
| 278 | + process.on('SIGINT', () => { |
| 279 | + console.log('\n[SHUTDOWN] Exiting and closing connections...') |
| 280 | + clearInterval(statsInterval) |
| 281 | + for (const ws of zombies) { |
| 282 | + if (ws && ws.readyState === WebSocket.OPEN) ws.close() |
| 283 | + } |
| 284 | + setTimeout(() => process.exit(0), 1000) |
| 285 | + }) |
| 286 | +} |
| 287 | + |
| 288 | +main().catch((err: unknown) => { |
| 289 | + console.error('Fatal error:', err) |
| 290 | + process.exit(1) |
| 291 | +}) |
0 commit comments