diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5d65fea..2ecc5b3 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -19,6 +19,7 @@ jobs: auth.docker.io:443 github.com:443 production.cloudflare.docker.com:443 + production.cloudfront.docker.com:443 registry-1.docker.io:443 registry.npmjs.org:443 security.ubuntu.com:80 diff --git a/CHANGELOG.md b/CHANGELOG.md index af637e0..b65ce71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG - throttle +# UNRELEASED +### Fixed +* Linux throttling no longer fails with "Exclusivity flag on, cannot modify" when another tool already holds the ingress qdisc slot on the interface. GitHub-hosted Actions runners now come with an eBPF network-monitoring agent that attaches a clsact qdisc to the default interface (other eBPF-based security agents do the same), and since only one ingress/clsact qdisc can exist per device, throttle's `tc qdisc add ... ingress` failed and took the whole measurement run down with it. The original code path is unchanged and runs first; only when it fails does throttle fall back to attaching its redirect filters to the existing qdisc (on the clsact ingress hook when needed), with explicit filter priorities so stop removes exactly its own filters and leaves the other tool's qdisc and filters untouched. + # 6.0.0 - 2026-03-23 ### Added * Added IPv6 throttling support on macOS [#94](https://github.com/sitespeedio/throttle/pull/94) and Linux [#100](https://github.com/sitespeedio/throttle/pull/100). diff --git a/lib/tc.js b/lib/tc.js index 31750c2..ae192b9 100644 --- a/lib/tc.js +++ b/lib/tc.js @@ -93,6 +93,101 @@ async function setup(defaultInterface) { ); } +// Only used when setup() fails because another tool already holds the +// ingress qdisc slot. Explicit filter priorities make it possible to +// remove exactly these filters on stop without touching filters added +// by whoever owns the qdisc. High numbers run last, after auto-assigned +// priorities (49152 and down), so monitoring agents see the traffic +// before it is redirected to ifb0. +const FILTER_PREF_IP = '65000'; +const FILTER_PREF_IPV6 = '65001'; + +async function getIngressSlotKind(defaultInterface) { + const result = await shell(`sudo tc qdisc show dev ${defaultInterface}`); + const match = result.stdout.match(/^qdisc (ingress|clsact) ffff:/m); + return match ? match[1] : undefined; +} + +async function setupOnBusyIngressSlot(defaultInterface, setupError) { + // The ingress slot is exclusive per device and can already be held by + // another tool: GitHub-hosted runners come with an eBPF monitoring + // agent that attaches a clsact qdisc to the default interface, which + // makes the plain qdisc add in setup() fail with "Exclusivity flag on, + // cannot modify". Filters can attach to the existing hook instead, but + // clsact only accepts them on its ffff:fff2 ingress class. + const kind = await getIngressSlotKind(defaultInterface); + if (kind !== 'ingress' && kind !== 'clsact') { + throw setupError; + } + const parent = kind === 'clsact' ? 'ffff:fff2' : 'ffff:'; + + for (const [protocol, pref] of [ + ['ip', FILTER_PREF_IP], + ['ipv6', FILTER_PREF_IPV6] + ]) { + await sudo( + 'tc', + 'filter', + 'add', + 'dev', + defaultInterface, + 'parent', + parent, + 'protocol', + protocol, + 'pref', + pref, + 'u32', + 'match', + 'u32', + '0', + '0', + 'flowid', + '1:1', + 'action', + 'mirred', + 'egress', + 'redirect', + 'dev', + 'ifb0' + ); + } +} + +async function removeBusyIngressSlotFilters(indexFace) { + // Only relevant when setupOnBusyIngressSlot() attached the filters to a + // qdisc that belongs to another tool: the qdisc deletes in stop() leave + // that qdisc alone (they fail on kind mismatch), so remove exactly our + // filters and nothing else. + const kind = await getIngressSlotKind(indexFace); + if (!kind) { + return; + } + const parent = kind === 'clsact' ? 'ffff:fff2' : 'ffff:'; + for (const [protocol, pref] of [ + ['ip', FILTER_PREF_IP], + ['ipv6', FILTER_PREF_IPV6] + ]) { + try { + await sudo( + 'tc', + 'filter', + 'del', + 'dev', + indexFace, + 'parent', + parent, + 'protocol', + protocol, + 'pref', + pref + ); + } catch { + // ignore + } + } +} + async function setLimits(up, down, halfWayRTT, packetLoss, indexFace) { if (down) { const parameters = [ @@ -184,7 +279,11 @@ export async function start(up, down, rtt = 0, packetLoss = 0) { const indexFace = await getDefaultInterface(); await moduleProbe(); await setupifb0(); - await setup(indexFace); + try { + await setup(indexFace); + } catch (error) { + await setupOnBusyIngressSlot(indexFace, error); + } await setLimits(up, down, halfWayRTT, packetLoss, indexFace); } export async function stop() { @@ -202,6 +301,12 @@ export async function stop() { // ignore } + try { + await removeBusyIngressSlotFilters(indexFace); + } catch { + // ignore + } + try { await sudo('tc', 'qdisc', 'del', 'dev', 'ifb0', 'root'); } catch { diff --git a/test/Dockerfile b/test/Dockerfile index 3e8a986..2c39843 100644 --- a/test/Dockerfile +++ b/test/Dockerfile @@ -1,4 +1,4 @@ -FROM sitespeedio/node:ubuntu-20.04-nodejs-16.5.0 +FROM sitespeedio/node:ubuntu-24-04-nodejs-24.18.0 RUN apt-get update && apt-get install libnss3-tools iproute2 sudo net-tools -y RUN mkdir -p /usr/src/app diff --git a/test/start.sh b/test/start.sh index dc72101..a8c159e 100755 --- a/test/start.sh +++ b/test/start.sh @@ -5,4 +5,25 @@ set -e bin/index.js --profile 3gslow bin/index.js --stop bin/index.js --profile cable +bin/index.js --stop + +## The ingress slot can already be taken by another tool, for example the +## eBPF monitoring agent on hardened GitHub Actions runners (clsact). +## Throttle should attach its filters to the existing qdisc and leave the +## qdisc alone on stop. +INTERFACE=$(ip route | awk '/default/ {print $5; exit}') +tc qdisc add dev "$INTERFACE" clsact +bin/index.js --profile cable +tc filter show dev "$INTERFACE" ingress | grep -q mirred +bin/index.js --stop +tc qdisc show dev "$INTERFACE" | grep -q clsact +if tc filter show dev "$INTERFACE" ingress | grep -q mirred; then + echo "throttle filters were not cleaned up" >&2 + exit 1 +fi +tc qdisc del dev "$INTERFACE" clsact + +## Same thing when a plain ingress qdisc already exists +tc qdisc add dev "$INTERFACE" ingress +bin/index.js --profile cable bin/index.js --stop \ No newline at end of file