@vpalmisano/throttler 0.0.7

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.
@@ -0,0 +1,562 @@
1
+ import { spawn } from 'child_process'
2
+ import fs from 'fs'
3
+ import JSON5 from 'json5'
4
+ import os from 'os'
5
+
6
+ import {
7
+ checkNetworkInterface,
8
+ getDefaultNetworkInterface,
9
+ logger,
10
+ runShellCommand,
11
+ toPrecision,
12
+ } from './utils'
13
+
14
+ const log = logger('throttler:throttle')
15
+
16
+ let throttleConfig: ThrottleConfig[] | null = null
17
+
18
+ const ruleTimeouts = new Set<NodeJS.Timeout>()
19
+
20
+ const captureStops = new Map<number, () => Promise<void>>()
21
+
22
+ const throttleCurrentValues = {
23
+ up: new Map<
24
+ number,
25
+ {
26
+ rate?: number
27
+ delay?: number
28
+ delayJitter?: number
29
+ delayJitterCorrelation?: number
30
+ loss?: number
31
+ lossBurst?: number
32
+ queue?: number
33
+ }
34
+ >(),
35
+ down: new Map<
36
+ number,
37
+ {
38
+ rate?: number
39
+ delay?: number
40
+ delayJitterCorrelation?: number
41
+ loss?: number
42
+ lossBurst?: number
43
+ queue?: number
44
+ }
45
+ >(),
46
+ }
47
+
48
+ async function cleanup(): Promise<void> {
49
+ await Promise.allSettled([...captureStops.values()].map(stop => stop()))
50
+ captureStops.clear()
51
+ ruleTimeouts.forEach(timeoutId => clearTimeout(timeoutId))
52
+ ruleTimeouts.clear()
53
+ throttleCurrentValues.up.clear()
54
+ throttleCurrentValues.down.clear()
55
+ let device = throttleConfig?.length ? throttleConfig[0].device : ''
56
+ if (!device) {
57
+ device = await getDefaultNetworkInterface()
58
+ }
59
+ await runShellCommand(`\
60
+ sudo -n tc qdisc del dev ${device} root || true;
61
+ sudo -n tc class del dev ${device} || true;
62
+ sudo -n tc filter del dev ${device} || true;
63
+ sudo -n tc qdisc del dev ${device} ingress || true;
64
+
65
+ sudo -n tc qdisc del dev ifb0 root || true;
66
+ sudo -n tc class del dev ifb0 root || true;
67
+ sudo -n tc filter del dev ifb0 root || true;
68
+ `)
69
+ await cleanupRules()
70
+ }
71
+
72
+ function calculateBufferedPackets(
73
+ rate: number,
74
+ delay: number,
75
+ mtu = 1500,
76
+ ): number {
77
+ // https://lists.linuxfoundation.org/pipermail/netem/2007-March/001094.html
78
+ return Math.ceil((((1.5 * rate * 1000) / 8) * (delay / 1000)) / mtu)
79
+ }
80
+
81
+ /** The network throttle rules to be applied to uplink or downlink. */
82
+ export type ThrottleRule = {
83
+ /** The available bandwidth (Kbps). */
84
+ rate?: number
85
+ /** The one-way delay (ms). */
86
+ delay?: number
87
+ /** The one-way delay jitter (ms). */
88
+ delayJitter?: number
89
+ /** The one-way delay jitter correlation. */
90
+ delayJitterCorrelation?: number
91
+ /** The delay distribution. */
92
+ delayDistribution?: 'uniform' | 'normal' | 'pareto' | 'paretonormal'
93
+ /** The packet reordering percentage. */
94
+ reorder?: number
95
+ /** The packet reordering correlation. */
96
+ reorderCorrelation?: number
97
+ /** The packet reordering gap. */
98
+ reorderGap?: number
99
+ /** The packet loss percentage. */
100
+ loss?: number
101
+ /** The packet loss burst. */
102
+ lossBurst?: number
103
+ /** The packet queue size. */
104
+ queue?: number
105
+ /** If set, the rule will be applied after the specified number of seconds. */
106
+ at?: number
107
+ }
108
+
109
+ /**
110
+ * The network throttling rules.
111
+ * Specify multiple {@link ThrottleRule} with different `at` values to schedule
112
+ * network bandwidth/delay fluctuations during the test run, e.g.:
113
+ *
114
+ * ```javascript
115
+ * {
116
+ device: "eth0",
117
+ sessions: "0-1",
118
+ protocol: "udp",
119
+ down: [
120
+ { rate: 1000000, delay: 50, loss: 0, queue: 5 },
121
+ { rate: 200000, delay: 100, loss: 5, queue: 5, at: 60},
122
+ ],
123
+ up: { rate: 100000, delay: 50, queue: 5 },
124
+ capture: 'capture.pcap',
125
+ }
126
+ * ```
127
+ */
128
+ export type ThrottleConfig = {
129
+ /** The network interface to throttle. If not specified, the default interface will be used. */
130
+ device?: string
131
+ /** The sessions to throttle. It could be a single index ("0"), a range ("0-2") or a comma-separated list ("0,3,4"). */
132
+ sessions?: string
133
+ /** The protocol to throttle. */
134
+ protocol?: 'udp' | 'tcp'
135
+ /** A comma-separated list of source ports that will not be throttled. */
136
+ skipSourcePorts?: string
137
+ /** A comma-separated list of destination ports that will not be throttled. */
138
+ skipDestinationPorts?: string
139
+ /** An additional IPTables packet filter rule. */
140
+ filter?: string
141
+ /** An additional TC match expression used to filter packets (https://man7.org/linux/man-pages/man8/tc-ematch.8.html). */
142
+ match?: string
143
+ /** If set, the packets matching the provided session and protocol will be captured at that file location. */
144
+ capture?: string
145
+ /** The uplink throttle rules. */
146
+ up?: ThrottleRule | ThrottleRule[]
147
+ /** The downlink throttle rules. */
148
+ down?: ThrottleRule | ThrottleRule[]
149
+ }
150
+
151
+ async function applyRules(
152
+ config: ThrottleConfig,
153
+ direction: 'up' | 'down',
154
+ device: string,
155
+ index: number,
156
+ protocol?: 'udp' | 'tcp',
157
+ match?: string,
158
+ ): Promise<void> {
159
+ let rules = config[direction]
160
+ if (!rules) return
161
+ log.info(
162
+ `applyRules device=${device} index=${index} protocol=${protocol} match=${match} ${JSON.stringify(
163
+ rules,
164
+ )}`,
165
+ )
166
+ if (!Array.isArray(rules)) {
167
+ rules = [rules]
168
+ }
169
+ rules.sort((a, b) => {
170
+ return (a.at || 0) - (b.at || 0)
171
+ })
172
+
173
+ for (const [i, rule] of rules.entries()) {
174
+ const {
175
+ rate,
176
+ delay,
177
+ delayJitter,
178
+ delayJitterCorrelation,
179
+ delayDistribution,
180
+ reorder,
181
+ reorderCorrelation,
182
+ reorderGap,
183
+ loss,
184
+ lossBurst,
185
+ queue,
186
+ at,
187
+ } = rule
188
+ const limit = queue ?? calculateBufferedPackets(rate || 0, delay || 0)
189
+ const mark = index + 1
190
+ const handle = index + 2
191
+
192
+ if (i === 0) {
193
+ const matches = [`'meta(nf_mark eq ${mark})'`]
194
+ if (protocol === 'udp') {
195
+ matches.push("'cmp(u8 at 9 layer network eq 0x11)'")
196
+ } else if (protocol === 'tcp') {
197
+ matches.push("'cmp(u8 at 9 layer network eq 0x6)'")
198
+ }
199
+ if (match) {
200
+ matches.push(match)
201
+ }
202
+ const cmd = `\
203
+ set -e;
204
+
205
+ sudo -n tc class add dev ${device} parent 1: classid 1:${handle} htb rate 1Gbit ceil 1Gbit;
206
+
207
+ sudo -n tc qdisc add dev ${device} \
208
+ parent 1:${handle} \
209
+ handle ${handle}: \
210
+ netem; \
211
+
212
+ sudo -n tc filter add dev ${device} \
213
+ parent 1: \
214
+ protocol ip \
215
+ basic match ${matches.join(' and ')} \
216
+ flowid 1:${handle};
217
+ `
218
+ try {
219
+ await runShellCommand(cmd, true)
220
+ } catch (err) {
221
+ log.error(`error running "${cmd}": ${(err as Error).stack}`)
222
+ throw err
223
+ }
224
+ }
225
+
226
+ const timeoutId = setTimeout(
227
+ async () => {
228
+ let desc = ''
229
+
230
+ if (rate && rate > 0) {
231
+ desc += ` rate ${rate}kbit`
232
+ }
233
+
234
+ if (limit && limit > 0) {
235
+ desc += ` limit ${limit}`
236
+ }
237
+
238
+ if (delay && delay > 0) {
239
+ desc += ` delay ${delay}ms`
240
+ if (delayJitter && delayJitter > 0) {
241
+ desc += ` ${delayJitter}ms`
242
+ if (delayJitterCorrelation && delayJitterCorrelation > 0) {
243
+ desc += ` ${delayJitterCorrelation}`
244
+ }
245
+ }
246
+ if (delayDistribution) {
247
+ desc += ` distribution ${delayDistribution}`
248
+ }
249
+ }
250
+
251
+ if (loss && loss > 0) {
252
+ if (lossBurst && lossBurst > 0) {
253
+ const p = (100 * loss) / (lossBurst * (100 - loss))
254
+ const r = 100 / lossBurst
255
+ desc += ` loss gemodel ${toPrecision(p, 2)} ${toPrecision(r, 2)}`
256
+ } else {
257
+ desc += ` loss ${toPrecision(loss, 2)}%`
258
+ }
259
+ }
260
+
261
+ if (reorder && reorder > 0) {
262
+ desc += ` reorder ${toPrecision(reorder, 2)}%`
263
+ if (reorderCorrelation && reorderCorrelation > 0) {
264
+ desc += ` ${toPrecision(reorderCorrelation, 2)}`
265
+ }
266
+ if (reorderGap && reorderGap > 0) {
267
+ desc += ` gap ${reorderGap}`
268
+ }
269
+ }
270
+
271
+ log.info(`applying rules on ${device} (${mark}): ${desc}`)
272
+ const cmd = `\
273
+ sudo -n tc qdisc change dev ${device} \
274
+ parent 1:${handle} \
275
+ handle ${handle}: \
276
+ netem ${desc}`
277
+ try {
278
+ ruleTimeouts.delete(timeoutId)
279
+
280
+ await runShellCommand(cmd)
281
+
282
+ throttleCurrentValues[direction].set(index, {
283
+ rate: rate ? 1000 * rate : undefined,
284
+ delay: delay || undefined,
285
+ loss: loss || undefined,
286
+ queue: limit || undefined,
287
+ })
288
+ } catch (err) {
289
+ log.error(`error running "${cmd}": ${(err as Error).stack}`)
290
+ }
291
+ },
292
+ (at || 0) * 1000,
293
+ )
294
+
295
+ ruleTimeouts.add(timeoutId)
296
+ }
297
+ }
298
+
299
+ async function start(): Promise<void> {
300
+ if (!throttleConfig || !throttleConfig.length) return
301
+
302
+ let device = throttleConfig[0].device
303
+ if (device) {
304
+ try {
305
+ await checkNetworkInterface(device)
306
+ } catch (_err) {
307
+ log.warn(`Network interface ${device} not found, using default.`)
308
+ device = ''
309
+ }
310
+ }
311
+ if (!device) {
312
+ device = await getDefaultNetworkInterface()
313
+ }
314
+
315
+ await runShellCommand(
316
+ `\
317
+ set -e;
318
+
319
+ sudo -n modprobe ifb || true;
320
+ sudo -n ip link add ifb0 type ifb || true;
321
+ sudo -n ip link set dev ifb0 up;
322
+
323
+ sudo -n tc qdisc add dev ${device} root handle 1: htb default 1;
324
+ sudo -n tc class add dev ${device} parent 1: classid 1:1 htb rate 1Gbit ceil 1Gbit;
325
+
326
+ sudo -n tc qdisc add dev ifb0 root handle 1: htb default 1;
327
+ sudo -n tc class add dev ifb0 parent 1: classid 1:1 htb rate 1Gbit ceil 1Gbit;
328
+
329
+ sudo -n tc qdisc add dev ${device} ingress handle ffff: || true;
330
+ sudo -n tc filter add dev ${device} \
331
+ parent ffff: \
332
+ protocol ip \
333
+ u32 \
334
+ match u32 0 0 \
335
+ action connmark \
336
+ action mirred egress \
337
+ redirect dev ifb0 \
338
+ flowid 1:1;
339
+ `,
340
+ true,
341
+ )
342
+
343
+ let index = 0
344
+ for (const config of throttleConfig) {
345
+ if (config.up) {
346
+ await applyRules(
347
+ config,
348
+ 'up',
349
+ device,
350
+ index,
351
+ config.protocol,
352
+ config.match,
353
+ )
354
+ }
355
+ if (config.down) {
356
+ await applyRules(
357
+ config,
358
+ 'down',
359
+ 'ifb0',
360
+ index,
361
+ config.protocol,
362
+ config.match,
363
+ )
364
+ }
365
+ if (config.capture) {
366
+ captureStops.set(
367
+ index,
368
+ capturePackets(index, config.capture, config.protocol),
369
+ )
370
+ }
371
+ index++
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Starts a network throttle configuration
377
+ * @param config A JSON5 configuration parsed as {@link ThrottleConfig}.
378
+ */
379
+ export async function startThrottle(config: string): Promise<void> {
380
+ if (os.platform() !== 'linux') {
381
+ throw new Error('Throttle option is only supported on Linux')
382
+ }
383
+ try {
384
+ throttleConfig = JSON5.parse(config) as ThrottleConfig[]
385
+ log.debug('Starting throttle with config:', throttleConfig)
386
+ await cleanup()
387
+ await start()
388
+ } catch (err) {
389
+ log.error(`startThrottle "${config}" error: ${(err as Error).stack}`)
390
+ await stopThrottle()
391
+ throw err
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Stops the network throttle.
397
+ */
398
+ export async function stopThrottle(): Promise<void> {
399
+ if (os.platform() !== 'linux') {
400
+ throw new Error('Throttle option is only supported on Linux')
401
+ }
402
+ try {
403
+ log.debug('Stopping throttle')
404
+ await cleanup()
405
+ log.debug('Stopping throttle done')
406
+ throttleConfig = null
407
+ } catch (err) {
408
+ log.error(`Stop throttle error: ${(err as Error).stack}`)
409
+ }
410
+ }
411
+
412
+ export function getSessionThrottleIndex(sessionId: number): number {
413
+ if (!throttleConfig) return -1
414
+
415
+ for (const [index, config] of throttleConfig.entries()) {
416
+ if (config.sessions === undefined || config.sessions === '') {
417
+ continue
418
+ }
419
+ try {
420
+ if (config.sessions.includes('-')) {
421
+ const [start, end] = config.sessions.split('-').map(Number)
422
+ if (sessionId >= start && sessionId <= end) {
423
+ return index
424
+ }
425
+ } else if (config.sessions.includes(',')) {
426
+ const sessions = config.sessions.split(',').map(Number)
427
+ if (sessions.includes(sessionId)) {
428
+ return index
429
+ }
430
+ } else if (sessionId === Number(config.sessions)) {
431
+ return index
432
+ }
433
+ } catch (err) {
434
+ log.error(
435
+ `getSessionThrottleId sessionId=${sessionId} error: ${(err as Error).stack}`,
436
+ )
437
+ }
438
+ }
439
+
440
+ return -1
441
+ }
442
+
443
+ export function getSessionThrottleValues(
444
+ index: number,
445
+ direction: 'up' | 'down',
446
+ ): {
447
+ rate?: number
448
+ delay?: number
449
+ loss?: number
450
+ queue?: number
451
+ } {
452
+ if (index < 0) {
453
+ return {}
454
+ }
455
+ return throttleCurrentValues[direction].get(index) || {}
456
+ }
457
+
458
+ export async function throttleLauncher(
459
+ executablePath: string,
460
+ index: number,
461
+ ): Promise<string> {
462
+ log.debug(`throttleLauncher executablePath=${executablePath} index=${index}`)
463
+ if (!throttleConfig || index < 0) {
464
+ return executablePath
465
+ }
466
+ const config = throttleConfig[index]
467
+ const mark = index + 1
468
+ const launcherPath = `/tmp/throttler-launcher-${index}`
469
+ const group = `throttler${index}`
470
+ const filters = `${config.protocol ? `-p ${config.protocol}` : ''}\
471
+ ${config.skipSourcePorts ? ` -m multiport ! --sports ${config.skipSourcePorts}` : ''}\
472
+ ${config.skipDestinationPorts ? ` -m multiport ! --dports ${config.skipDestinationPorts}` : ''}\
473
+ ${config.filter ? ` ${config.filter}` : ''}`
474
+ await fs.promises.writeFile(
475
+ launcherPath,
476
+ `#!/bin/bash
477
+ getent group ${group} >/dev/null || sudo -n addgroup --system ${group}
478
+ sudo -n adduser $USER ${group} --quiet
479
+
480
+ rule=$(sudo -n iptables -t mangle -L OUTPUT --line-numbers | grep "owner GID match ${group}" | awk '{print $1}')
481
+ if [ -n "$rule" ]; then
482
+ sudo -n iptables -t mangle -R OUTPUT \${rule} ${filters} -m owner --gid-owner ${group} -j MARK --set-mark ${mark}
483
+ else
484
+ sudo -n iptables -t mangle -I OUTPUT 1 ${filters} -m owner --gid-owner ${group} -j MARK --set-mark ${mark}
485
+ fi
486
+
487
+ sudo -n iptables -t mangle -L PREROUTING | grep -q "CONNMARK restore" || sudo -n iptables -t mangle -I PREROUTING 1 -j CONNMARK --restore-mark
488
+ sudo -n iptables -t mangle -L POSTROUTING | grep -q "CONNMARK save" || sudo -n iptables -t mangle -I POSTROUTING 1 -j CONNMARK --save-mark
489
+
490
+ function stop() {
491
+ echo "Stopping throttler"
492
+ }
493
+ trap stop SIGINT SIGTERM
494
+
495
+ echo "running: ${executablePath} $@"
496
+ exec newgrp ${group} <<EOF
497
+ ${executablePath} $@
498
+ EOF`,
499
+ )
500
+ await fs.promises.chmod(launcherPath, 0o755)
501
+ return launcherPath
502
+ }
503
+
504
+ async function cleanupRules(): Promise<void> {
505
+ if (!throttleConfig?.length) return
506
+ log.debug(`cleanupRules (${throttleConfig.length})`)
507
+ try {
508
+ await runShellCommand(`\
509
+ for i in $(seq 0 ${throttleConfig.length}); do
510
+ rule=$(sudo -n iptables -t mangle -L OUTPUT --line-numbers | grep "owner GID match throttler\${i}" | awk '{print $1}');
511
+ if [ -n "$rule" ]; then
512
+ sudo -n iptables -t mangle -D OUTPUT \${rule};
513
+ fi;
514
+ done;`)
515
+ } catch (err) {
516
+ log.error(`cleanupRules error: ${(err as Error).stack}`)
517
+ }
518
+ }
519
+
520
+ function capturePackets(
521
+ index: number,
522
+ filePath: string,
523
+ protocol?: string,
524
+ ): () => Promise<void> {
525
+ const mark = index + 1
526
+ log.info(`Starting capture ${filePath}`)
527
+ const cmd = `#!/bin/bash
528
+ sudo -n iptables -L INPUT | grep -q "nflog-group ${mark}" || sudo -n iptables -A INPUT ${protocol ? `-p ${protocol}` : ''} -m connmark --mark ${mark} -j NFLOG --nflog-group ${mark}
529
+ sudo -n iptables -L OUTPUT | grep -q "nflog-group ${mark}" || sudo -n iptables -A OUTPUT ${protocol ? `-p ${protocol}` : ''} -m connmark --mark ${mark} -j NFLOG --nflog-group ${mark}
530
+ exec dumpcap -q -i nflog:${mark} -w ${filePath}
531
+ `
532
+ const proc = spawn(cmd, {
533
+ shell: true,
534
+ stdio: ['ignore', 'ignore', 'pipe'],
535
+ detached: true,
536
+ })
537
+ let stderr = ''
538
+ proc.stderr.on('data', data => {
539
+ stderr += data
540
+ })
541
+ proc.on('error', err => {
542
+ log.error(`Error running command capturePackets ${err}: ${stderr}`)
543
+ })
544
+ proc.once('exit', code => {
545
+ if (code) {
546
+ log.error(`capturePackets exited with code ${code}: ${stderr}`)
547
+ } else {
548
+ log.info(`capturePackets exited`)
549
+ }
550
+ })
551
+
552
+ const stop = async () => {
553
+ log.info(`Stopping capture ${filePath}`)
554
+ proc.kill('SIGINT')
555
+ await runShellCommand(`#!/bin/bash
556
+ sudo -n iptables -D INPUT ${protocol ? `-p ${protocol}` : ''} -m connmark --mark ${mark} -j NFLOG --nflog-group ${mark}
557
+ sudo -n iptables -D OUTPUT ${protocol ? `-p ${protocol}` : ''} -m connmark --mark ${mark} -j NFLOG --nflog-group ${mark}
558
+ `)
559
+ }
560
+
561
+ return stop
562
+ }