Skip to content

Instantly share code, notes, and snippets.

@jikamens
Last active February 3, 2023 15:32
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jikamens/df069af107b79cefab909c848e79f44c to your computer and use it in GitHub Desktop.
Save jikamens/df069af107b79cefab909c848e79f44c to your computer and use it in GitHub Desktop.
auto-dnsbl.py - add DNSBL entries to /etc/hosts.deny automatically
#!/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 <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 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