Recently I’ve been maxing out my download bandwidth on the regular as I assist various data archiving projects. This is bumming out my wife because it interferes with her online meetings and my kids because it messes up their gaming performance. Up to now I’ve just been having them complain to me when performance is bad and then I temporarily stop the downloads and resume them later. This isn’t ideal because (a) I don’t actually want to completely stop the downloads, I just want to slow them down to make room for other traffic, (b) I don’t want to be interrupted frequently to deal with this, and (c) I don’t want to have to resume the downloads when my family is done with whatever needs bandwidth, especially if that happens long after I’m asleep (my kids game until late into the night).
So today I wrote a trivial little CGI script and installed it on the computer doing the downloads. Anyone in my family who notices network performance issues can visit the URL of the script on my computer, and it automatically confirms that I’m using a lot of bandwidth, and if so throttles the bandwidth down to 50% for an hour then automatically unthrottles it. If they visit the URL again at any time during that hour it’s extended until an hour after that.
The script uses ifconfig
to find out how much download bandwidth my computer is using, wondershaper
to throttle the bandwidth, and at
to schedule the job an hour into the future to unthrottle the bandwidth. Because wondershaper
has to run as root, it uses sudo
to invoke it, which means sudo
needs to be configured to allow the user that the web server is running as to run wondershaper
as root.
Below is the script, for those of you who are curious.
#!/usr/bin/env perl
# To deploy on Debian:
# 1. Copy script to /usr/lib/cgi-bin/shaper
# 2. Install /usr/local/bin/wondershaper
# 3. /etc/sudoers.d/apache-wondershaper:
# www-data ALL=(root:ALL) NOPASSWD: /usr/local/bin/wondershaper
# 4. Remove www-data from /etc/at.deny
use CGI qw(header param url);
use Getopt::Long;
use POSIX 'strftime';
my $url = url;
my $iface = "enp2s0";
my $busy_mbytes_per_second = 15;
my $cgi = $ENV{REQUEST_URI} ? 1 : 0;
my $wondershaper = "sudo /usr/local/bin/wondershaper -a $iface";
my $content = <<EOF;
<html>
<head><title>Download throttler</title></head>
<script>
async function main() {
let response, text;
const status = document.getElementById("status");
status.innerText = "Checking if already throttled...";
response = await fetch("$url?get_throttled=1");
if (!response.ok) {
status.innerText += " ERROR.";
return;
}
status.innerText += " done.";
text = await response.text();
if (text.startsWith("YES")) {
status.innerText += "\\nAlready throttled.";
status.innerText += "\\nExtending...";
response = await fetch("$url?extend=1");
text = await response.text();
if (!response.ok || !text.startsWith("OK ")) {
status.innerText += " ERROR.";
return;
}
status.innerText += " done.\\nThrottle extended to " + text.substr(3) + ".";
return;
}
status.innerText += "\\nChecking bandwidth consumption...";
response = await fetch("$url?get_busy=1");
if (!response.ok) {
status.innerText += " ERROR.";
return;
}
status.innerText += " done.";
text = await response.text();
if (!text.startsWith("YES")) {
status.innerText += "\\nBandwidth usage is too low to throttle.";
return;
}
status.innerText += "\\nThrottling bandwidth...";
response = await fetch("$url?throttle=1");
text = await response.text();
if (response.ok && text.startsWith("OK ")) {
status.innerText += " done.\\nThrottled until " + text.substr(3) + ".";
}
else {
status.innerText += " ERROR.";
return;
}
}
window.addEventListener("load", main);
</script>
<body>
<p id="status">Stand by...</p>
</body>
</html>
EOF
die if (! GetOptions("cgi" => \$cgi));
# Returns integer
sub get_rx_bytes {
local $_ = `ifconfig $iface`;
die if (! /\bRX.* bytes (\d+)/);
return($1);
}
# Returns true or false
sub is_busy {
my $check_seconds = 10;
my $before = get_rx_bytes;
sleep($check_seconds);
my $after = get_rx_bytes;
my $delta = $after - $before;
my $mbytes_per_second = $delta / 1024 / 1024 / $check_seconds;
return $mbytes_per_second > $busy_mbytes_per_second * 1.25;
}
# Returns true or false
sub is_throttled {
my $status = `$wondershaper -s`;
return $status =~ /\bingress\b/;
}
# Dies if unsuccessful, no return value
sub clear_timer {
local($_);
my(@jobs) = `at -l`;
die if ($?);
foreach my $job (@jobs) {
$job =~ /(\d)+/ or die;
my $jobnum = $1;
my $script = `at -c $jobnum`;
die if ($?);
if ($script =~ /$wondershaper -c/) {
system("at -d $jobnum") and die;
last;
}
}
}
# Returns "HH:MM" on success, dies on failure
sub set_timer {
my $until = strftime("%H:%M", localtime(time + 60 * 60));
clear_timer;
open(AT, "|-", "at", $until) or die;
print(AT "$wondershaper -c") or die;
close(AT) or die;
return $until;
}
# Returns "OK HH:MM" on success, false on failure
sub throttle {
my $max_kbits_per_second = int($busy_mbytes_per_second * 1024 * 10);
return -1 if (! unthrottle);
my($cmd) = "$wondershaper -d $max_kbits_per_second";
print(STDERR $cmd);
my $ret = system($cmd);
if ($ret) {
print(STDERR "$cmd failed (status=$ret)\n");
return;
}
my $until = set_timer;
return $until;
}
# Returns true on success, false on failure
sub unthrottle {
my $cmd = "$wondershaper -c";
print(STDERR $cmd);
my $ret = system($cmd);
# wondershaper -c exits with status 2 even on success.
if ($ret && ($ret >> 8 != 2)) {
print(STDERR "$cmd failed (status=$ret)\n");
return 0;
}
clear_timer;
return 1;
}
# Returns true on success, false on failure
sub cgi {
if (scalar(param("get_busy"))) {
print(header("text/plain"));
print(is_busy ? "YES" : "NO");
return 1;
}
if (scalar(param("get_throttled"))) {
print(header("text/plain"));
print(is_throttled ? "YES" : "NO");
return 1;
}
if (scalar(param("throttle"))) {
print(header("text/plain"));
my $ret = throttle;
print($ret ? "OK $ret" : "ERROR");
return $ret;
}
if (scalar(param("extend"))) {
print(header("text/plain"));
my $ret = set_timer;
print($ret ? "OK $ret" : "ERROR");
return $ret;
}
if (scalar(param("unthrottle"))) {
print(header("text/plain"));
my $ret = unthrottle;
print($ret ? "OK" : "ERROR");
return $ret;
}
print header();
print $content;
return 1;
}
if ($cgi) {
exit cgi;
}