Last active
February 3, 2023 15:32
-
-
Save jikamens/df069af107b79cefab909c848e79f44c to your computer and use it in GitHub Desktop.
auto-dnsbl.py - add DNSBL entries to /etc/hosts.deny automatically
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 /etc/hosts.deny 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 your /etc/hosts.deny file, 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<$deny_file> | |
The file into which to put the IP addresses being blocked. Make sure | |
you know what you're doing if you set this to something other than | |
F</etc/hosts.deny>. | |
=item C<$section_start>, C<$section_end> | |
You probably don't need to change these. They indicate the lines that | |
should be used in C<$deny_file> to denote the beginning and end of the | |
list of automatically generated and maintained IP addresses being | |
banned. | |
=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 /etc/hosts.deny from DNSBL lookups | |
After=network.target | |
Requires=iptables.target | |
PartOf=iptables.target | |
[Service] | |
Type=simple | |
ExecStart=/usr/local/bin/auto-dnsbl.pl | |
Restart=always | |
StandardOutput=null | |
StandardError=null | |
[Install] | |
WantedBy=multi-user.target | |
The script's output is discarded in the unit-file configuration shown | |
above because any output it generates is also logged to syslog, and | |
there's no need to capture the output twice. | |
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 <[email protected]>. | |
Feel free to contact me with questions, comments, bug reports, etc. | |
=head1 DONATIONS | |
L<https://paypal.me/JonathanKamens> | |
=head1 COPYRIGHT | |
Copyright (c) 2017 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 VERSION | |
This version of the script was released on 2017-04-09. | |
=cut | |
use strict; | |
use warnings; | |
use Carp::Syslog 'daemon'; | |
use Date::Parse; | |
# TODO: Multiple log files | |
my $log_file = 'tail --follow=name /var/log/maillog|'; | |
my(@regexes) = ( | |
qr|badlogin: (?:\S+ )?\[(\d+\.\d+\.\d+\.\d+)\].*: authentication failure: Password verification failed|, | |
qr|\[(\d+\.\d+\.\d+\.\d+)\] (?:\(may be forged\) )?did not issue MAIL|, | |
qr|Message from (\d+\.\d+\.\d+\.\d+) rejected - see http://www\.spamhaus|, | |
); | |
my $deny_file = '/etc/hosts.deny'; | |
my $section_start = "# AUTO-DNSBL START"; | |
my $section_end = "# AUTO-DNSBL END"; | |
my @dnsbls = ('xbl.spamhaus.org', 'sbl.spamhaus.org'); | |
my $block_for = 60 * 60 * 24; # Block for one day | |
# In: deny file name | |
# Out: Opaque object representing parsed deny file | |
sub parse { | |
my($deny_file) = @_; | |
my($preamble) = ''; | |
my($postamble) = ''; | |
my(@ips, $on, $after, $stamp); | |
open(DENY_FILE, '<', $deny_file) or die "open(<$deny_file): $!\n"; | |
while (<DENY_FILE>) { | |
if ($after) { | |
$postamble .= $_; | |
next; | |
} | |
if (! $on) { | |
if (/^$section_start/o) { | |
$on = 1; | |
} | |
else{ | |
$preamble .= $_; | |
} | |
next; | |
} | |
if (/^$section_end/o) { | |
$on = undef; | |
$after = 1; | |
next; | |
} | |
if (/^# Added (.*)/) { | |
die "Duplicate timestamp $1 at line $. of $deny_file\n" | |
if ($stamp); | |
$stamp = str2time($1); | |
die "Bad timestamp $1 at line $. of $deny_file\n" if (! $stamp); | |
next; | |
} | |
if (/^ALL: (\d+.\d+.\d+.\d+)$/) { | |
die "Missing stamp before $_ at line $. of $deny_file\n" | |
if (! $stamp); | |
push(@ips, [$stamp, $1]); | |
$stamp = undef; | |
next; | |
} | |
die "Unrecognized line $. in $deny_file: $1\n"; | |
} | |
die "Missing $section_end in $deny_file\n" if ($on); | |
return { | |
'preamble' => $preamble, | |
'postamble' => $postamble, | |
'ips' => \@ips, | |
}; | |
} | |
# In: Parsed deny file object | |
# Out: None | |
# Side effects: Saves deny file to disk | |
sub save { | |
my($denier) = @_; | |
open(DENY_FILE, '>', "$deny_file.new") | |
or die "open(>$deny_file.new): $!\n"; | |
if ($denier->{'preamble'}) { | |
print(DENY_FILE $denier->{'preamble'}) or die; | |
} | |
my(@ips) = @{$denier->{'ips'}}; | |
if (@ips) { | |
print(DENY_FILE "$section_start\n") or die; | |
foreach my $ip (@ips) { | |
print(DENY_FILE "# Added " . localtime($ip->[0]) . "\n") or die; | |
print(DENY_FILE "ALL: $ip->[1]\n") or die; | |
} | |
print(DENY_FILE "$section_end\n") or die; | |
} | |
if ($denier->{'postamble'}) { | |
print(DENY_FILE $denier->{'postamble'}) or die; | |
} | |
close(DENY_FILE) or die; | |
rename("$deny_file.new", $deny_file) | |
or die "rename($deny_file.new, $deny_file): $!\n"; | |
} | |
# In: Parsed deny file 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 (@{$denier->{'ips'}}) { | |
if ($ip->[0] <= $then) { | |
warn "Expiring: $ip->[1]\n"; | |
} | |
else { | |
push(@new, $ip) | |
} | |
} | |
$denier->{'ips'} = \@new; | |
} | |
# In: Parsed deny file object, IP address to add | |
# Out: None | |
# Side effects: Adds record for IP | |
sub add { | |
my($denier, $ip) = @_; | |
warn "Adding: $ip\n"; | |
push(@{$denier->{'ips'}}, [time(), $ip]); | |
} | |
# In: Denier, IP address | |
# Out: True if IP is already in denier | |
sub in { | |
my($denier, $ip) = @_; | |
return(grep($_->[1] eq $ip, @{$denier->{'ips'}})); | |
} | |
# In: blocklist, IP | |
# Out: True if in blocklist | |
sub check_blocklist { | |
my($dnsbl, $ip) = @_; | |
my(@numbers) = split(/\./, $ip); | |
my $lookup = join('.', reverse @numbers) . '.' . $dnsbl; | |
return gethostbyname($lookup); | |
} | |
# In: Blocklists ref, IP | |
# Out: True if in any of them | |
sub check_blocklists { | |
my($dnsbls, $ip) = @_; | |
foreach my $dnsbl (@{$dnsbls}) { | |
return 1 if (&check_blocklist($dnsbl, $ip)); | |
} | |
return undef; | |
} | |
# In: logfile, regexes, deny file, section start, section end, dnsbls, | |
# block_for | |
# Out: None | |
# Side effects: Launches and runs forever, processing and updating deny file | |
sub daemon { | |
my($log_file, $regexes, $deny_file, $section_start, $section_end, $dnsbls, | |
$block_for) = @_; | |
my($denier, $denier_stamp); | |
open(LOGFILE, $log_file) or die; | |
$denier = &parse($deny_file); | |
$denier_stamp = (stat($deny_file))[9]; | |
while (<LOGFILE>) { | |
my $ip; | |
foreach my $regex (@{$regexes}) { | |
if (/$regex/) { | |
$ip = $1; | |
last; | |
} | |
} | |
next if (! $ip); | |
if (&in($denier, $ip)) { | |
warn "Skipping (already in $deny_file): $ip\n"; | |
next; | |
} | |
if (! &check_blocklists($dnsbls, $ip)) { | |
warn "Skipping (not in blocklist): $ip\n"; | |
next; | |
} | |
my $new_stamp = (stat($deny_file))[9]; | |
if (! $denier or $new_stamp != $denier_stamp) { | |
$denier = &parse($deny_file); | |
$denier_stamp = $new_stamp; | |
} | |
&purge($denier, $block_for); | |
&add($denier, $ip); | |
&save($denier); | |
$denier_stamp = (stat($deny_file))[9]; | |
} | |
} | |
&daemon($log_file, \@regexes, $deny_file, $section_start, $section_end, | |
\@dnsbls, $block_for); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment