App-BlockWebFlooders

 view release on metacpan or  search on metacpan

Changes  view on Meta::CPAN

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.


Changes  view on Meta::CPAN

	- [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 )