App-BlockWebFlooders

 view release on metacpan or  search on metacpan

script/block-web-flooders  view on Meta::CPAN

}

sub connectdb {
    require DBI;
    require SQL::Schema::Versioned;

    return if $Dbh;

    $Dbh = DBI->connect("dbi:SQLite:dbname=/var/run/block-web-flooders.db");

    my $res = SQL::Schema::Versioned::create_or_update_db_schema(
        dbh => $Dbh, spec => $Dbspec);
    die "Cannot initialize DB: $res->[0] - $res->[1]" unless $res->[0] == 200;
}

sub _set_need_reload {
    $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;

    _init();

    # get IP's from command-line arguments if specified, otherwise from stdin
    my $iter;
    if (@ARGV) {
        require Array::Iter;
        $iter = Array::Iter::array_iter(\@ARGV);
    } else {
        $iter = sub { scalar <STDIN> };
    }

    while (defined(my $ip = $iter->())) {
        chomp($ip);
        unless ($ip =~ /\A$RE{ipv4}\z/) {
            warn "$PROG: Invalid IP address '$ip', skipped\n";
            next;
        }
        _block_or_unblock_ip($which, $ip, 1); # don't update messages
    }
}

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};

    my $last_check_spanel_log_time;
    my ($spanel_http_log_name, $spanel_https_log_name);

    my $last_update_output_time;
    my $last_unblock_time;
    my $last_reload_data_time;
    my $num_lines = 0;

    _init();

    local *INPUT;
    if (defined $Opts{spanel_site}) {
        require Tie::Handle::TailSwitch;
        my $dir = "/s/$Opts{spanel_site}/syslog";
        tie *INPUT, 'Tie::Handle::TailSwitch', (
            globs => ["$dir/https_access.*.log", "$dir/http_access.*.log"],
        );
    } else {
        *INPUT = \*STDIN;
    }

  LINE:
    while (1) {
        my $line = <INPUT>;
        if (!defined($line) || !length($line)) {
            sleep 0.5;
            next;
        }

        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";
            $i = 0;
            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();
            $last_reload_data_time = $now;
        }

        for my $has (@{ $Opts{has} }) {
            next LINE unless index($line, $has) >= 0;
        }
        for my $lacks (@{ $Opts{lacks} }) {
            next LINE if index($line, $lacks) >= 0;
        }
        for my $pat (@{ $Opts{has_pattern} }) {
            next LINE unless $line =~ $pat;
        }
        for my $pat (@{ $Opts{lacks_pattern} }) {
            next LINE if $line =~ $pat;
        }

        $Ips{$ip} //= do {
            tie my @ary, "Tie::Array::Expire", $Opts{period};
            \@ary;
        };
        push @{ $Ips{$ip} }, 1;
        if (@{ $Ips{$ip} } > $Opts{limit} && !$Whitelisted{$ip}) {
            block_ip($ip);
            delete $Ips{$ip};
        }
    } # loop
}

# 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;

script/block-web-flooders  view on Meta::CPAN


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:

 93.186.253.79 - - [07/Jun/2017:00:54:23 +0000] "GET /heavy1.php HTTP/1.0" 200 20633 "-" "Opera/9.80 (Windows NT 6.0; U; en) Presto/2.2.0 Version/10.00"
 51.15.41.74 - - [07/Jun/2017:00:54:25 +0000] "POST /heavy2.php HTTP/1.1" 302 - "-" "Opera/9.80 (Windows NT 6.0; U; en) Presto/2.2.0 Version/10.00"
 89.38.149.5 - - [07/Jun/2017:00:54:24 +0000] "GET /heavy1.php HTTP/1.0" 200 20633 "-" "Opera/9.80 (Windows NT 6.0; U; en) Presto/2.2.0 Version/10.00"
 93.186.253.79 - - [07/Jun/2017:00:54:24 +0000] "GET /heavy3.php HTTP/1.0" 200 20524 "-" "Opera/9.80 (Windows NT 6.0; U; en) Presto/2.2.0 Version/10.00"
 51.15.41.74 - - [07/Jun/2017:00:54:25 +0000] "GET /heavy1.php HTTP/1.0" 200 20633 "-" "Opera/9.80 (Windows NT 6.0; U; en) Presto/2.2.0 Version/10.00"
 89.38.149.5 - - [07/Jun/2017:00:54:25 +0000] "GET /heavy3.php HTTP/1.0" 200 20524 "-" "Opera/9.80 (Windows NT 6.0; U; en) Presto/2.2.0 Version/10.00"
 89.38.149.5 - - [07/Jun/2017:00:54:25 +0000] "GET /heavy3.php HTTP/1.0" 200 20524 "-" "Opera/9.80 (Windows NT 6.0; U; en) Presto/2.2.0 Version/10.00"
 93.186.253.79 - - [07/Jun/2017:00:54:26 +0000] "POST /heavy2.php HTTP/1.1" 302 - "-" "Opera/9.80 (Windows NT 6.0; U; en) Presto/2.2.0 Version/10.00"
 51.15.41.74 - - [07/Jun/2017:00:54:25 +0000] "GET /heavy1.php HTTP/1.0" 200 20633 "-" "Opera/9.80 (Windows NT 6.0; U; en) Presto/2.2.0 Version/10.00"
 89.36.213.37 - - [07/Jun/2017:00:54:26 +0000] "GET /heavy3.php HTTP/1.0" 200 20524 "-" "Opera/9.80 (Windows NT 6.0; U; en) Presto/2.2.0 Version/10.00"
 89.36.213.37 - - [07/Jun/2017:00:54:27 +0000] "POST /heavy2.php HTTP/1.1" 302 - "-" "Opera/9.80 (Windows NT 6.0; U; en) Presto/2.2.0 Version/10.00"
 89.38.149.5 - - [07/Jun/2017:00:54:26 +0000] "GET /heavy1.php HTTP/1.0" 200 20633 "-" "Opera/9.80 (Windows NT 6.0; U; en) Presto/2.2.0 Version/10.00"
 89.36.213.37 - - [07/Jun/2017:00:54:26 +0000] "GET /heavy1.php HTTP/1.0" 200 20633 "-" "Opera/9.80 (Windows NT 6.0; U; en) Presto/2.2.0 Version/10.00"

you can add C<--has Presto/2.2.0> and C<--has /heavy> since these quite
accurately selects the flood requests. If you can add strings which pretty
accurately single out the flood requests, you can use a lower threshold speed,
e.g. C<--limit 5> to block IPs which has requested 5 or more in the last 5
minutes. Otherwise, if you do not have any specific C<--has> to single out the
flood, you might need to set a higher limit, e.g. C<--has html --limit 30
--period 60> to block IPs which have requested 30 or more requests in the last
minute, or C<--limit 200 --period 120> to block IPs which have requested 200 or
more requests in the last 2 minutes.

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:

 #

=head1 DESCRIPTION

This script helps a sysadmin when there is a flood from multiple IP addresses to
your website. The script works by reading web access log file, considering lines
which match the specified pattern(s), then block the IP address of the requester
if the speed of request from that IP exceeds a limit. The blocking is done using
firewall (L<iptables>), by default:

 # iptables -A INPUT -s <ip-address> -p tcp -m multiport --dports 80,443 -j DROP

To use this script, see the general guide in the Synopsis.

=head1 OPTIONS

=over

=item * --has=S

=item * --has-pattern=REGEX

=item * --lacks=S

=item * --lacks-pattern=REGEX

=item * --limit=N

=item * --period=N

=item * --whitelist-ip=IP

=item * --spanel-site=NAME

=item * --dry-run

=back

=head1 TODO

Option to customize ports.

Parse timestamps from web access logs so it can also parse past lines.

IPv6 support.

Some interactivity, e.g.: reset counters, unblock some IPs, increase/decrease
limit.

=head1 HOMEPAGE

Please visit the project's homepage at L<https://metacpan.org/release/App-BlockWebFlooders>.

=head1 SOURCE

Source repository is at L<https://github.com/perlancar/perl-App-BlockWebFlooders>.

=head1 BUGS

Please report any bugs or feature requests on the bugtracker website L<https://rt.cpan.org/Public/Dist/Display.html?Name=App-BlockWebFlooders>

When submitting a bug or request, please include a test-file or a
patch to an existing test-file that illustrates the bug or desired
feature.

=head1 SEE ALSO

CSF, L<https://configserver.com/cp/csf.html>, the more generic solution.

=head1 AUTHOR



( run in 0.498 second using v1.01-cache-2.11-cpan-13bb782fe5a )