App-BlockWebFlooders
view release on metacpan or search on metacpan
0.010 2019-01-29 (PERLANCAR)
- Add --action and actions: block, unblock, unblock_all,
list_blocked.
- [bugfix] Don't die when unblocking an IP fails (e.g. iptables has
been cleared).
- [ux] Prevent running multiple instances.
- [ux] Check for known config parameters and sections, die on
unknown param.
- [Bugfix] Fix code ordering so display update is proper.
0.007 2018-11-10 (PERLANCAR)
- [Bugfix] --has-pattern and --lacks-pattern were not considered.
0.006 2018-11-10 (PERLANCAR)
- [Bugfix] Display the correct number of blocked IPs.
0.005 2018-11-10 (PERLANCAR)
- Now by default only blocks an IP for 86400 seconds.
- Store blocklist in SQLite database to persist between runs.
- [doc] Forgot to list --spanel-site under Options.
script/block-web-flooders view on Meta::CPAN
use Regexp::Pattern 'Net::ipv4';
use Term::ANSIColor;
use Tie::Array::Expire;
use Time::HiRes 'sleep';
my $PROG = 'block-web-flooders';
my $Dbspec = {
latest_v => 1,
install => [
'CREATE TABLE blocked (ip TEXT NOT NULL PRIMARY KEY, ctime INT NOT NULL)',
],
};
my $action = "run";
my $opt_detail;
my %Opts = (
limit => undef,
has => [],
lacks => [],
has_pattern => [],
lacks_pattern => [],
period => 300,
block_period => 86400,
whitelist_ip => [],
# ports => [80, 443],
spanel_site => undef,
);
my %Whitelisted; # key = ip address
my $Dbh;
my %Blocked; # key = ip address, value = unix time (time blocked)
my %Ips; # key = ip address, value = expiring array
tie my @Messages, "Tie::Array::Expire", 15;
sub read_config {
require Config::IOD::Reader;
my $iod = Config::IOD::Reader->new();
for my $dir ("$ENV{HOME}/.config", $ENV{HOME}, "/etc") {
script/block-web-flooders view on Meta::CPAN
means 24 hours a.k.a. 1 day).
--dry-run Do not actually block with iptables, simulation mode.
--spanel-site=NAME
Instead of piping output of tail -f manually, you can use this
on an Spanel server to automatically locate the HTTP & HTTPS
log files and switch to the newest files.
--action=S The default ("run") is to read from file/stdin continuously and
block IP's as we run. Other actions include: "block" and
"unblock" to block (unblock) one or more IP addresses specified
in command-line arguments or stdin, "list_blocked" to list the
IPs currently blocked, "unblock_all" to unblock all IP's.
--block Shortcut for --action=block.
--unblock Shortcut for --action=unblock.
--list-blocked Shortcut for --action=list_blocked.
--unblock-all Shortcut for --action=unblock_all.
--detail, -l When action=list_blocked, show more data.
For more details, see the documentation (man $PROG).
EOT
exit 0;
},
'version|v' => sub {
no warnings 'once';
print "$PROG version ", ($main::VERSION // "dev"), "\n";
exit 0;
},
'action=s' => \$action,
'block' => sub { $action = 'block' },
'unblock' => sub { $action = 'unblock' },
'list-blocked' => sub { $action = 'list_blocked' },
'unblock-all' => sub { $action = 'unblock_all' },
'detail|l' => \$opt_detail,
'whitelist-ip=s' => $Opts{whitelist_ip},
'has=s' => $Opts{has},
'has-pattern=s' => $Opts{has_pattern},
'lacks=s' => $Opts{lacks},
'lacks-pattern=s' => $Opts{lacks_pattern},
'limit=i' => \$Opts{limit},
script/block-web-flooders view on Meta::CPAN
$Dbh->do("INSERT OR IGNORE INTO meta (name,value) VALUES ('_need_reload',0)");
$Dbh->do("UPDATE meta SET value=$_[0] WHERE name='_need_reload'");
}
sub _reload_data_from_db {
my $force = shift;
my ($need_reload) = $Dbh->selectrow_array("SELECT value FROM meta WHERE name='_need_reload'");
return if !$force && defined $need_reload && !$need_reload;
%Blocked = ();
my $sth = $Dbh->prepare("SELECT * FROM blocked");
$sth->execute;
while (my $row = $sth->fetchrow_hashref) {
$Blocked{ $row->{ip} } = $row->{ctime};
}
unshift @Messages, "(re)loaded data from db";
_set_need_reload(0);
}
sub _init {
connectdb();
_reload_data_from_db(1); # force
%Whitelisted = map { $_=>1 } @{ $Opts{whitelist_ip} };
}
sub _block_or_unblock_ip {
my ($which, $ip, $update_messages) = @_;
$update_messages //= 1;
if ($which eq 'block') {
return if $Blocked{$ip};
} else {
return unless $Blocked{$ip};
}
system(
{
die => ($which eq 'block' ? 1:0),
dry_run => $Opts{dry_run},
(capture_stderr => \my $stderr) x ($which eq 'block' ? 0:1),
},
"iptables", ($which eq 'block' ? "-A" : "-D"), "INPUT", "-s", $ip,
"-p", "tcp", "-m", "multiport", "--dports", "80,443",
"-j", "DROP",
);
my $now = time();
if ($which eq 'block') {
unshift @Messages, "$ip BLOCKED".($Opts{dry_run} ? " (dry-run)" : "")
if $update_messages;
$Dbh->do("INSERT OR IGNORE INTO blocked (ip,ctime) VALUES (?,?)", {}, $ip, $now);
$Blocked{$ip} = time();
} else {
unshift @Messages, "$ip unblocked".($Opts{dry_run} ? " (dry-run)" : "")
if $update_messages;
$Dbh->do("DELETE FROM blocked WHERE ip=?", {}, $ip);
delete $Blocked{$ip};
}
}
sub block_ip { _block_or_unblock_ip("block", @_) }
sub unblock_ip { _block_or_unblock_ip("unblock", @_) }
sub _block_or_unblock_ips {
my $which = shift;
script/block-web-flooders view on Meta::CPAN
sub action_block {
_block_or_unblock_ips("block");
_set_need_reload(1);
}
sub action_unblock {
_block_or_unblock_ips("unblock");
_set_need_reload(1);
}
sub action_list_blocked {
_init();
my $now = time();
for (sort { $Blocked{$a} <=> $Blocked{$b} } keys %Blocked) {
my $secs = $Opts{block_period} - ($now - $Blocked{$_});
$secs = 0 if $secs < 0;
if ($opt_detail) {
printf "%s\t%d\n", $_, $secs;
} else {
print $_, "\n";
}
}
}
sub action_unblock_all {
_init();
local @ARGV = keys %Blocked;
_block_or_unblock_ips("unblock");
_set_need_reload(1);
}
sub action_run {
#require Term::Size;
require Time::Duration;
#my ($columns, $rows) = Term::Size::chars *STDOUT{IO};
script/block-web-flooders view on Meta::CPAN
}
my $now = time();
$num_lines++;
chomp $line;
$line =~ /\A($RE{ipv4})\s/ or do {
warn "$PROG: Line '$line': Can't parse IP address, skipped\n";
next;
};
my $ip = $1;
next if $Blocked{$ip};
OUTPUT:
{
last unless !$last_update_output_time ||
$last_update_output_time <= $now-2;
print "\e[2J\e[;H"; # clear screen + put cursor at top (0,0)
printf "Blocked IPs: %s%4d%s | Log lines: %s%6d%s | Running for: %s%s%s\n",
color('bold'), (scalar keys %Blocked), color('reset'),
color('bold'), $num_lines, color('reset'),
color('bold'), Time::Duration::concise(Time::Duration::duration($now-$^T, 2)), color('reset');
$last_update_output_time = $now;
printf "Top IPs:\n";
my $i = 0;
for my $ip (sort { scalar(@{ $Ips{$b} }) <=> scalar(@{ $Ips{$a} }) } keys %Ips) {
last if $i++ >= 10;
printf " %15s (%4d)\n", $ip, scalar(@{ $Ips{$ip} });
}
printf "Last messages:\n";
script/block-web-flooders view on Meta::CPAN
for my $msg (@Messages) {
last if $i++ >= 5;
print " $msg\n";
}
} # OUTPUT
UNBLOCK:
{
last unless !$last_unblock_time ||
$last_unblock_time <= $now-60;
for (keys %Blocked) {
next unless $Blocked{$_} < $now - $Opts{block_period};
unblock_ip($_);
}
$last_unblock_time = $now;
} # UNBLOCK
RELOAD_DATA:
{
last unless !$last_reload_data_time ||
$last_reload_data_time <= $now-5;
_reload_data_from_db();
script/block-web-flooders view on Meta::CPAN
# MAIN
die "$PROG: Please run me as root\n" if $>;
read_config();
parse_options();
if ($action eq 'block') {
action_block();
} elsif ($action eq 'unblock') {
action_unblock();
} elsif ($action eq 'list_blocked') {
action_list_blocked();
} elsif ($action eq 'unblock_all') {
action_unblock_all();
} elsif ($action eq 'run') {
require Sys::RunAlone::Flexible;
Sys::RunAlone::Flexible::lock();
action_run();
} else {
die "$PROG: Unknown action '$action'\n";
}
script/block-web-flooders view on Meta::CPAN
This script should be run as root/sudo root, because it needs to call the
L<iptables> command to add block rules to the firewall.
First of all, create F</etc/block-web-flooders.conf> that contains something
like this:
whitelist_ip = 1.2.3.4
whitelist_ip = ...
Where C<1.2.3.4> is the IP address(es) that you are connecting from (you can see
this from output of L<w> command), to make sure you yourself don't get blocked.
Add more lines/IP address as necessary.
When a flood is happening, try to tail your web access log file:
# tail -f /s/example.com/syslog/https_access.2017-06-07.log
and see the patterns that you can use to discriminate the requests coming from
the flooder. Since the IP address is usually random/many, you can see from other
patterns e.g. requested URI, user agent. For example, if the suspicious log
lines are something like this:
script/block-web-flooders view on Meta::CPAN
Feed the output of the C<tail> command to this script:
# tail -f /s/example.com/syslog/https_access.2017-06-07.log | block-web-flooders \
--has Presto/2.2.0 --has-pattern '/heavy|/baz' --limit 5
or perhaps:
# tail -f /s/example.com/syslog/https_access.2017-06-07.log | block-web-flooders \
--limit 200 --period 120
The script will display the top IP addresses and whether an IP is being blocked,
along with some statistics:
Blocked IPs this session: 12 | Log lines: 198 | Running for: 2m13s
Top IPs:
89.36.213.37 ( 4)
89.38.149.5 ( 2)
93.186.253.79 ( 2)
...
Last messages:
51.15.41.74 BLOCKED
While this script is running, you might also want to open something like this in
another terminal (monitor incoming web requests):
# tail -f /s/example.com/syslog/https_access.2017-06-07.log | grep /heavy
and somethins like this in yet another terminal (monitor system load and number
of web server processes, this depends on the web server you use):
# watch 'w | head -n1; echo -n "Blocked IPs total: "; iptables -nL INPUT | wc -l; echo -n "Apache processes: "; ps ax | grep apache | wc -l'
If your webserver is still maxed out by requests, you might want to tweak
C<--limit> and C<--period> options and restart the web server.
To see the blocked IP addresses:
# iptables -nL INPUT
As long as the script runs, IP addresses are blocked by default temporarily for
86400 seconds (or, according to the --block-period command-line option or
block_period configuration). After that block period is exceeded, the IP is
unblocked.
To immediately clear/unblock all the IPs:
# iptables -F INPUT
(this is assuming the default policy of input is ACCEPT; if you have a firewall
package installed, please follow the procedure for that firewall.)
To immediately unblock some IPs:
( run in 0.495 second using v1.01-cache-2.11-cpan-49f99fa48dc )