Skip to content

Instantly share code, notes, and snippets.

@jikamens
Last active February 17, 2023 05:07
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jikamens/07220fc98361421c2ddfabb5286c14d8 to your computer and use it in GitHub Desktop.
Save jikamens/07220fc98361421c2ddfabb5286c14d8 to your computer and use it in GitHub Desktop.
auto-dnsbl.pl (iptables version)
#!/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