Last active
May 27, 2024 08:06
-
-
Save jikamens/07220fc98361421c2ddfabb5286c14d8 to your computer and use it in GitHub Desktop.
auto-dnsbl.pl (iptables version)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env perl | |
=pod | |
=head1 SUMMARY | |
auto-dnsbl.py - add DNSBL entries to iptables automatically | |
=head1 DESCRIPTION | |
This script monitors log messages with IP addresses indicating | |
probably nefarious activity, checks the IP addresses against a DNS | |
Blackhole List (DNSBL), and adds the addresses that are in the DNSBL | |
to iptables, so that further connections from those addresses will be | |
blocked automatically. | |
Its effect is thus similar to fail2ban's. However, it differs in two | |
important respects: | |
=over | |
=item 1. | |
Fail2ban generally relies on repeated misdeeds from a single IP | |
address before it is banned, whereas this script bans based on only a | |
single log entry plus presence in the DNSBL. | |
=item 2. | |
Because a misdeed combined with presence in the DNSBL makes it very | |
likely that an IP address is up to no good, this script bans addresses | |
for much longer than is typically done by fail2ban (by default, this | |
script bans addresses for an entire day). | |
=back | |
=head1 CONFIGURATION | |
You can (actually, you almost certainly I<should>) configure the | |
script for your use by modifying the following variables below: | |
=over | |
=item C<$log_file> | |
The log file path or pipeline which should be read for log messages to | |
search for IP addresses. If you'd like you can keep the C<tail> | |
command that's shown by default, and just change the list of one or | |
more files you want to monitor at the end of the command. | |
=item C<@regexes> | |
The regular expressions to search for in the log messages. Each regex | |
in this list needs to have a parenthesized group matching the IP | |
address to look up. If there are multiple groups in the regex, all but | |
the first are ignored. | |
=item C<$state_file> | |
File that the script stores state in so it keeps working properly when | |
it is restarted. | |
=item C<@dnsbls> | |
The DNS Blackhole Lists in which to do lookups to determine whether to | |
ban an IP. | |
=item C<$block_for> | |
The number of seconds to ban IP addresses for. | |
=back | |
-head1 DEPLOYMENT | |
You could, e.g., put this script in F</usr/local/bin> and then create | |
F</etc/systemd/system/auto-dnsbl.service> with contents that look like | |
this: | |
[Unit] | |
Description=Auto-update iptables from DNSBL lookups | |
After=network.target | |
Requires=iptables.service | |
PartOf=iptables.service | |
[Service] | |
Type=simple | |
ExecStart=/usr/local/bin/auto-dnsbl.pl | |
Restart=always | |
[Install] | |
WantedBy=multi-user.target | |
Don't forget to do C<sudo systemctl daemon-reload> and then C<sudo | |
systemctl enable auto-dnsbl.service> and/or C<sudo systemctl start | |
auto-dnsbl.service> as desired. | |
=head1 AUTHOR | |
Written by Jonathan Kamens <jik@kamens.us>. | |
Feel free to contact me with questions, comments, bug reports, etc. | |
=head1 DONATIONS | |
L<https://paypal.me/JonathanKamens> | |
=head1 COPYRIGHT | |
Copyright (c) 2017,2022 Jonathan Kamens. | |
This program is free software: you can redistribute it and/or modify | |
it under the terms of the GNU General Public License as published by | |
the Free Software Foundation, either version 3 of the License, or (at | |
your option) any later version. | |
This program is distributed in the hope that it will be useful, but | |
WITHOUT ANY WARRANTY; without even the implied warranty of | |
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |
General Public License for more details. | |
See L<http://www.gnu.org/licenses/>. | |
=head1 TODO | |
The script should use a dedicated chain rather than the INPUT chain. | |
=head1 HISTORY | |
This used to use tcpwrappers instead of iptables, but tcpwrappers is | |
deprecated now. You will note vestiges of the original tcpwrappers | |
implementation in the code, e.g., it talks about parsing when there's | |
no parsing now that we're storing the state in a DB file. | |
=head1 VERSION | |
This version of the script was released on 2022-01-18. | |
=cut | |
use strict; | |
use warnings; | |
use AnyDBM_File; | |
use Net::IP; | |
# TODO: Multiple log files | |
my $log_file = 'tail --follow=name /var/log/maillog|'; | |
my(@regexes) = ( | |
qr|STARTTLS=server, error:.*\[(\d+\.\d+\.\d+\.\d+)\]|, | |
qr|STARTTLS=server, error:.*\[IPv6:([0-9a-f:]+)\]|, | |
qr|\[(\d+\.\d+\.\d+\.\d+)\] (?:\(may be forged\) )?did not issue MAIL|, | |
qr|\[IPv6:([0-9a-f:]+)\] (?:\(may be forged\) )?did not issue MAIL|, | |
qr|nrcpts=0, .*, relay=.*\[(\d+\.\d+\.\d+\.\d+)\]|, | |
qr|nrcpts=0, .*, relay=.*\[IPv6:([0-9a-f:]+)\]|, | |
qr|Message from (\d+\.\d+\.\d+\.\d+) rejected - see http://www\.spamhaus|, | |
qr|lost input channel from .*\[(\d+\.\d+\.\d+\.\d+)\] (?:\(may be forged\) )?to |, | |
qr|lost input channel from .*\[IPv6:([0-9a-f:]+)\] (?:\(may be forged\) )?to |, | |
qr|ruleset=check_rcpt, .*relay=.*\[(\d+\.\d+\.\d+\.\d+)\](?: \(may be forged\))?, reject=|, | |
qr|ruleset=check_rcpt, .*relay=.*\[IPv6:([0-9a-f:]+)\](?: \(may be forged\))?, reject=|, | |
qr|badlogin: (?:\S+ )?\[(\d+\.\d+\.\d+\.\d+)\].*: authentication failure: checkpass failed|, | |
); | |
my $state_file = '/run/auto-dnsbl.state'; | |
my @dnsbls = ('xbl.spamhaus.org', 'sbl.spamhaus.org'); | |
my $block_for = 60 * 60 * 24; # Block for one day | |
# In: state file name | |
# Out: Opaque object representing state | |
sub parse { | |
my($state_file) = @_; | |
dbmopen(my %state_file, $state_file, 0600) or die; | |
my(%current); | |
foreach my $prot (qw(iptables ip6tables)) { | |
open(IPTABLES, "-|", $prot, "-n", "-L", "INPUT") or die; | |
while (<IPTABLES>) { | |
@_ = split; | |
next if ($_[0] ne "REJECT"); | |
$current{$_[3]} = 1; | |
} | |
} | |
for (keys %state_file) { | |
if (! $current{$_}) { | |
delete $state_file{$_}; | |
} | |
} | |
return \%state_file; | |
} | |
sub iptables { | |
return ($_[0] =~ /:/) ? 'ip6tables' : 'iptables'; | |
} | |
# In: Parsed state object, seconds to block IPs for | |
# Out: None | |
# Side effects: Removes stale records | |
sub purge { | |
my($denier, $block_for) = @_; | |
my $then = time() - $block_for; | |
my(@new); | |
foreach my $ip (keys %$denier) { | |
my $stamp = $denier->{$ip}; | |
if ($stamp <= $then) { | |
warn "Expiring: $ip\n"; | |
if (system(&iptables($ip), "-D", "INPUT", "-s", $ip, "-j", | |
"REJECT")) { | |
warn "Failed to remove $ip from iptables\n"; | |
} | |
delete $denier->{$ip}; | |
} | |
} | |
} | |
# In: Parsed state object, IP address to add | |
# Out: None | |
# Side effects: Adds record for IP | |
sub add { | |
my($denier, $ip) = @_; | |
warn "Adding: $ip\n"; | |
system(&iptables($ip), "-I", "INPUT", "-s", $ip, "-j", "REJECT") and die; | |
$denier->{$ip} = time(); | |
} | |
# In: Denier, IP address | |
# Out: True if IP is already in denier | |
sub in { | |
my($denier, $ip) = @_; | |
$denier->{$ip} ? 1 : 0; | |
} | |
# In: blocklist, IP | |
# Out: True if in blocklist | |
sub check_blocklist { | |
my($dnsbl, @numbers) = @_; | |
my $lookup = join('.', @numbers) . '.' . $dnsbl; | |
return gethostbyname($lookup); | |
} | |
# In: Blocklists ref, IP | |
# Out: True if in any of them | |
sub check_blocklists { | |
my($dnsbls, $ip) = @_; | |
my $ipo = new Net::IP($ip) or die; | |
my(@numbers); | |
if ($ipo->version() == 4) { | |
@numbers = reverse split(/\./, $ip); | |
} | |
else { | |
@numbers = reverse grep(! /:/, split(//, $ipo->ip())); | |
} | |
foreach my $dnsbl (@{$dnsbls}) { | |
return 1 if (&check_blocklist($dnsbl, @numbers)); | |
} | |
return undef; | |
} | |
# In: logfile, regexes, state file, dnsbls, block_for | |
# Out: None | |
# Side effects: Launches and runs forever, processing and updating state | |
sub daemon { | |
my($log_file, $regexes, $state_file, $dnsbls, $block_for) = @_; | |
my($denier, %recent); | |
open(LOGFILE, $log_file) or die; | |
$denier = &parse($state_file); | |
&purge($denier, $block_for); | |
logline: | |
while (<LOGFILE>) { | |
my $ip; | |
foreach my $regex (@{$regexes}) { | |
if (/$regex/) { | |
$ip = $1; | |
last; | |
} | |
} | |
next if (! $ip); | |
next if ($ip =~ /^(?:127\..*|(?:0:)+1|::1)$/); | |
# There are two cases where we don't want to log again about a | |
# "recent" IP address: (1) when the IP appears twice very | |
# close together in time in the logs; (2) when, for reasons I | |
# don't quite understand perhaps related to TCP timeouts, the | |
# IP appears twice within about 2.5 hours. We use the latter | |
# as the timeout, which covers the former. | |
my $now = time(); | |
my $then = $now - 2.5 * 60 * 60; | |
foreach my $oldip (keys %recent) { | |
my $oldtime = $recent{$oldip}; | |
if ($oldtime < $then) { | |
delete $recent{$oldip}; | |
} | |
elsif ($oldip eq $ip) { | |
next logline; | |
} | |
} | |
$recent{$ip} = $now; | |
if (&in($denier, $ip)) { | |
warn "Skipping (already in $state_file): $ip\n"; | |
next; | |
} | |
if (! &check_blocklists($dnsbls, $ip)) { | |
warn "Skipping (not in blocklist): $ip\n"; | |
next; | |
} | |
&purge($denier, $block_for); | |
&add($denier, $ip); | |
} | |
} | |
&daemon($log_file, \@regexes, $state_file, \@dnsbls, $block_for); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment