From patchwork Mon Apr 27 14:31:16 2020 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Tim FitzGeorge X-Patchwork-Id: 3004 Return-Path: Received: from mail01.ipfire.org (mail01.haj.ipfire.org [172.28.1.202]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature ECDSA (P-384) client-signature ECDSA (P-384)) (Client CN "mail01.haj.ipfire.org", Issuer "Let's Encrypt Authority X3" (verified OK)) by web04.haj.ipfire.org (Postfix) with ESMTPS id 499nKl6jDWz3xQy for ; Mon, 27 Apr 2020 14:32:11 +0000 (UTC) Received: from mail02.haj.ipfire.org (mail02.haj.ipfire.org [172.28.1.201]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature ECDSA (P-384) client-signature ECDSA (P-384)) (Client CN "mail02.haj.ipfire.org", Issuer "Let's Encrypt Authority X3" (verified OK)) by mail01.ipfire.org (Postfix) with ESMTPS id 499nKl1rjMz1nk; Mon, 27 Apr 2020 14:32:11 +0000 (UTC) Received: from mail02.haj.ipfire.org (localhost [127.0.0.1]) by mail02.haj.ipfire.org (Postfix) with ESMTP id 499nKl0NKZz2yVn; Mon, 27 Apr 2020 14:32:11 +0000 (UTC) Received: from mail01.ipfire.org (mail01.haj.ipfire.org [172.28.1.202]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature ECDSA (P-384) client-signature ECDSA (P-384)) (Client CN "mail01.haj.ipfire.org", Issuer "Let's Encrypt Authority X3" (verified OK)) by mail02.haj.ipfire.org (Postfix) with ESMTPS id 499nKj3XsNz2xnp for ; Mon, 27 Apr 2020 14:32:09 +0000 (UTC) Received: from smtp.hosts.co.uk (smtp.hosts.co.uk [85.233.160.19]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature ECDSA (P-384) server-digest SHA384) (Client did not present a certificate) by mail01.ipfire.org (Postfix) with ESMTPS id 499nKj0dhhz32M for ; Mon, 27 Apr 2020 14:32:09 +0000 (UTC) Received: from [95.149.142.196] (helo=aragorn.hosts.co.uk.tfitzgeorge.me.uk) by smtp.hosts.co.uk with esmtpa (Exim) (envelope-from ) id 1jT4nm-0003Z9-6u; Mon, 27 Apr 2020 15:32:08 +0100 From: Tim FitzGeorge To: development@lists.ipfire.org Subject: [PATCH v2 1/8] ipblacklist: Main script Date: Mon, 27 Apr 2020 15:31:16 +0100 Message-Id: <20200427143123.6378-2-ipfr@tfitzgeorge.me.uk> X-Mailer: git-send-email 2.16.4 In-Reply-To: <20200427143123.6378-1-ipfr@tfitzgeorge.me.uk> References: <20200427143123.6378-1-ipfr@tfitzgeorge.me.uk> ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=lists.ipfire.org; s=202003rsa; t=1587997929; h=from:from:reply-to:subject:subject:date:date:message-id:message-id: to:to:cc:cc:in-reply-to:in-reply-to:references:references; bh=UymzgFsNLPCzCiWzflOtN5j0qd3gVGIPzJsdkkn2IUg=; b=BLvURApageYndKcymXj3astjToS/BKEu4pFciu/Yep6A/+7azFckBQiNkyaXoD7mHzs55H lTDJPUhY7EFxQxyG+rp3HP6uNUlXtbc6rJ0GR06Cfl7wahnsS8Er1ECdI9hlJIk25uz0Ox 01Dch7+vIBKQ0x16mddy+Ex72+HTGDVcIm7TUYg879UMsWCRLuMEXw6ze6GTMW8c/2VynP PIefiJOZzpfBYtU4bpZd2g/Z3DDFPdn03eN11KdZxK4ghAiMpVaM2LafxDAt/bY+zeEfyE De361VvYhROnzg+EVDkwqkL20KhBvMMOuD59buAAVOr+OJcYcA085FiX8lcb8A== ARC-Seal: i=1; s=202003rsa; d=lists.ipfire.org; t=1587997929; a=rsa-sha256; cv=none; b=hj1M/sYTRDFzf1D2GKbcB1ZFJuFAZ/jnpjKhpL/AI44cztcg/sf41/KYyPpcF9vSfKE5r+ xlGG/M5rX6E0Reb/gySbXz7SGwEbRFUxQSvq/mT5pLE4ZJZosyKrVmB7bgnAKAw3IYgox8 pgiGNItbWWIdlF3SG//8VU2KSZ90vUz8ia2U01i6102E8ihUGXwf95ERrexbA78Wte+QL5 nflV2cJy/yTAH+Mz/6CfzQ9RD7mSgoMnMaHOzGKxm6gNffVHAdcANT+3n2u0rdDUgzDup5 e2B+EYDUWhzGBvBJFW5y190AUHUbRpODfKABqst6bn3F+4B0BAXu4/VGvBpd7A== ARC-Authentication-Results: i=1; mail01.ipfire.org; dkim=none; spf=pass (mail01.ipfire.org: domain of ipfr@tfitzgeorge.me.uk designates 85.233.160.19 as permitted sender) smtp.mailfrom=ipfr@tfitzgeorge.me.uk Authentication-Results: mail01.ipfire.org; dkim=none; dmarc=none; spf=pass (mail01.ipfire.org: domain of ipfr@tfitzgeorge.me.uk designates 85.233.160.19 as permitted sender) smtp.mailfrom=ipfr@tfitzgeorge.me.uk X-Rspamd-Queue-Id: 499nKj0dhhz32M X-Spamd-Result: default: False [-3.37 / 11.00]; RCVD_TLS_LAST(0.00)[]; RCVD_VIA_SMTP_AUTH(0.00)[]; RECEIVED_SPAMHAUS_PBL(0.00)[95.149.142.196:received]; FROM_HAS_DN(0.00)[]; TO_DN_SOME(0.00)[]; RWL_MAILSPIKE_GOOD(0.00)[85.233.160.19:from]; R_SPF_ALLOW(-0.20)[+ip4:85.233.160.0/27]; MIME_GOOD(-0.10)[text/plain]; ARC_SIGNED(0.00)[i=1]; ARC_NA(0.00)[]; DMARC_NA(0.00)[tfitzgeorge.me.uk]; TO_MATCH_ENVRCPT_SOME(0.00)[]; MX_GOOD(-0.01)[]; RCPT_COUNT_TWO(0.00)[2]; MID_CONTAINS_FROM(1.00)[]; NEURAL_HAM(-0.96)[-0.964]; IP_REPUTATION_SPAM(0.01)[asn: 8622(0.00), country: GB(0.01), ip: 85.233.160.19(0.00)]; FROM_EQ_ENVFROM(0.00)[]; R_DKIM_NA(0.00)[]; MIME_TRACE(0.00)[0:+]; ASN(0.00)[asn:8622, ipnet:85.233.160.0/19, country:GB]; RCVD_COUNT_TWO(0.00)[2]; BAYES_HAM(-3.00)[99.99%]; RCVD_IN_DNSWL_LOW(-0.10)[85.233.160.19:from] X-Rspamd-Server: mail01.haj.ipfire.org X-BeenThere: development@lists.ipfire.org X-Mailman-Version: 2.1.29 Precedence: list List-Id: IPFire development talk List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Errors-To: development-bounces@lists.ipfire.org Sender: "Development" Responsible for downloading blacklists and creating/modifying IPSets Does all work involving creating, deleting and changing IPTables and IPSets. Signed-off-by: Tim FitzGeorge --- src/scripts/ipblacklist | 1382 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1382 insertions(+) create mode 100755 src/scripts/ipblacklist diff --git a/src/scripts/ipblacklist b/src/scripts/ipblacklist new file mode 100755 index 000000000..6f950214c --- /dev/null +++ b/src/scripts/ipblacklist @@ -0,0 +1,1382 @@ +#! /usr/bin/perl + +############################################################################ +# # +# IP Address blocklists for IPFire # +# # +# This 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 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. # +# # +# You should have received a copy of the GNU General Public License # +# along with IPFire; if not, write to the Free Software # +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # +# # +# Copyright (C) 2018 - 2020 The IPFire team # +# # +############################################################################ +# # +# This script uses a file containing blacklist details in # +# /var/ipfire/ipblacklist/sources as well as # +# /var/ipfire/ipblacklistsettings containing an enable/disable flag for # +# each source. # +# # +# Two IPTables chains are used: BLACKLISTIN and BLACKLISTOUT are inserted # +# inserted into the main INPUT, OUTPUT and FORWARD chains; they capture # +# packets other than for the ICMP protocol. # +# # +# For each blacklist that is loaded, a chain is created to optionally log # +# and then to drop matching packets. An IPSet is created containing the # +# addresses or networks blocked by the blacklist, and then rules are added # +# to the BLACKLISTIN and BLACKLISTOUT chains to jump to this chain if a # +# packet list matches the set. # +# # +# When checking for updates, the modification time is used for each source # +# and if necessary the list is downloaded. The downloaded list is # +# compared to the existing IPSet contents and entries created or deleted # +# as necessary. # +# # +############################################################################ + +use strict; +#use warnings; + +use Carp; +use Sys::Syslog qw(:standard :macros); +use HTTP::Request; +use LWP::UserAgent; + +require "/var/ipfire/general-functions.pl"; + +############################################################################ +# Configuration variables +# +# These variables give the locations of various files used by this script +############################################################################ + +my $settingsdir = "/var/ipfire/ipblacklist"; +my $savedir = "/var/lib/ipblacklist"; +my $tmpdir = "/var/tmp"; + +my $settings = "$settingsdir/settings"; +my $sources = "$settingsdir/sources"; +my $checked = "$settingsdir/checked"; +my $modified = "$settingsdir/modified"; +my $iptables_list = "/var/tmp/iptables.txt"; +my $getipstat = "/usr/local/bin/getipstat"; +my $iptables = "/sbin/iptables"; +my $ipset = "/usr/sbin/ipset"; +my $fcrontab = "/usr/bin/fcrontab"; +my $lockfile = "/var/run/ipblacklist.pid"; +my $proxy_settings = "${General::swroot}/proxy/settings"; +my $red_setting = "/var/ipfire/red/iface"; +my $detailed_log = "$tmpdir/ipblacklist_log.txt"; +my $active = "/var/ipfire/red/active"; + +# Other configuration items + +my $margin = 30; # Scheduling allowance for run time etc in seconds +my $count = 30; # Maximum time to wait for another instance (300s) +my $max_dl_fails = 3; # Ignore check rate limit for this number of failures +my $max_size_fraction = 0.7; # Maximum fill fraction of IPSet before enlarging. +my $min_ipset_entries = 1024; # The minimum size of an IPSet. +my $max_dl_bytes = 10_485_760; # Maximum number of bytes to download. +my %parsers = ( 'ip-or-net-list' => \&parse_ip_or_net_list, + 'dshield' => \&parse_dshield ); + +############################################################################ +# Default settings +# Should be overwritten by reading settings files +############################################################################ + +my %sources = ( ); + +my %settings = ( 'DEBUG' => 0, + 'LOGGING' => 'on', + 'ENABLE' => 'off' ); + +my %proxy_settings = ( 'UPSTREAM_PROXY' => '' ); # No Proxy in use + +############################################################################ +# Function prototypes +############################################################################ + +sub abort( $ ); +sub create_list( $ ); +sub create_ipset( $$$ ); +sub debug( $$ ); +sub delete_list( $ ); +sub disable_logging(); +sub disable_updates(); +sub do_delete(); +sub do_start(); +sub do_stop(); +sub do_update(); +sub download_list( $$$ ); +sub download_check_header_time( $$$ ); +sub download_wget( $$$ ); +sub enable_logging(); +sub enable_updates(); +sub get_ipsets(); +sub get_rate_seconds( $ ); +sub iptables( $ ); +sub ipset( $ ); +sub stop_ipset(); +sub is_connected(); +sub log_message( $$ ); +sub parse_dshield( $ ); +sub parse_ip_or_net_list( $ ); +sub read_ipset( $$$$ ); +sub update_list( $$$ ); + +############################################################################ +# Variables +############################################################################ + +my %chains; # The Blacklist IPSets already loaded +my %old_blacklist; # Already blocked IP Addresses and/or networks + # downloaded for current blacklist +my $update_status = 0; # Set to 1 to update status file +my $ipset_running = 0; # Set to 1 if IPSet process is running +my %status; # Status information +my %checked; # Time blacklists last changed +my %modified; # Time blacklists last modified +my $red_iface; # The name of the red interface + +############################################################################ +# Synchronise runs +############################################################################ + +# This script can be triggered either by cron or the WUI. If another +# instance is running, wait for it to finish or timeout. + +while (-r $lockfile and $count > 0) +{ + open LOCKFILE, '<', $lockfile or (abort "Can't open lockfile", last); + my $pid = ; + close LOCKFILE; + + chomp $pid; + + last unless (-e "/proc/$pid"); + + sleep 10; + $count--; +} + +# Create pid file before starting main processing + +open LOCKFILE, '>', '/var/run/ipblacklist.pid' or abort "Can't open PID file: $!"; +print LOCKFILE "$$\n"; +close LOCKFILE; + +############################################################################ +# Set up for update +############################################################################ + +mkdir $settingsdir unless (-d $settingsdir); + +# Connect to the system log + +openlog( "ipblacklist", "nofatal", LOG_USER); +log_message LOG_INFO, "Starting IP Blacklist processing"; + +# Read settings + +General::readhash( $settings, \%settings ) if (-e $settings); +General::readhash( $checked, \%checked ) if (-e $checked); +General::readhash( $modified, \%modified ) if (-e $modified); +General::readhash( $proxy_settings, \%proxy_settings ) if (-e $proxy_settings); + +if (-r $sources) +{ + debug 1, "Reading sources file"; + + eval qx|/bin/cat $sources|; +} + +if (-r $red_setting) +{ + open REDIF, '<', $red_setting or (abort "Can't open red interface file", exit); + $red_iface = ; + chomp $red_iface; + close REDIF; +} + +if (@ARGV) +{ + foreach my $cmd (@ARGV) + { + if ('update' =~ m/^$cmd/i) + { + # Called hourly when enabled and on setting changes. + # Update the blacklists. + + if ($settings{'ENABLE'} eq 'on') + { + do_update(); + } + } + elsif ('start' =~ m/^$cmd/i) + { + # Called during system startup. + # Restore saved blacklists. + # Don't do an update since can take too long. + + do_start() if ($settings{'ENABLE'} eq 'on'); + } + elsif ('stop' =~ m/^$cmd/i) + { + # Called when shutting down. + # Delete IPSets and IPTables chains + + do_stop(); + } + elsif ('restore' =~ m/^$cmd/i) + { + # Called after restoring backup. + # Delete IPSets and IPTables chains, then re-create them with the new + # (restored) settings and make sure updates are enabled. + + do_stop(); + + if ($settings{'ENABLE'} eq 'on') + { + do_start(); + enable_updates(); + } + else + { + disable_updates(); + } + } + elsif ('log-on' =~ m/^$cmd/i) + { + # Called from WUI. + # Create entries in IPTables chains to log dropped packets. + + if ($settings{'ENABLE'} eq 'on') + { + enable_logging(); + } + } + elsif ('log-off' =~ m/^$cmd/i) + { + # Called from WUI + # Delete entries in IPTables chains to log dropped packets. + + disable_logging(); + } + elsif ('enable' =~ m/^$cmd/i) + { + # Called from WUI to enable blacklists + # Do an update and then enable automatic updates + + if ($settings{'ENABLE'} eq 'on') + { + do_update(); + enable_updates(); + } + } + elsif ('disable' =~ m/^$cmd/i) + { + # Called from WUI to disable blacklists. + # Disable updates, delete IPSets, IPTables chains and save files. + + disable_updates(); + do_delete(); + } + else + { + print "Usage: $0 [update|start|stop|restart|log-on|log-off|enable|disable]\n"; + } + } +} +elsif ($settings{'ENABLE'} eq 'on') # Default action if none specified +{ + do_update(); +} + +stop_ipset(); + +if ($update_status) +{ + debug 1, "Writing updated status file"; + + General::writehash( $checked, \%checked ); + General::writehash( $modified, \%modified ); +} + +# Remove the pid file + +unlink $lockfile; + +log_message LOG_INFO, "Finished IP Blacklist processing"; +closelog(); + + +#------------------------------------------------------------------------------ +# sub do_stop +# +# Deletes all the IPTables chains and the IPSets +#------------------------------------------------------------------------------ + +sub do_stop() +{ + get_ipsets(); + + log_message LOG_NOTICE, "Stopping IP Blacklists"; + + foreach my $list ( sort keys %sources ) + { + delete_list( $list ) if (exists $chains{$list}); + } +} + + +#------------------------------------------------------------------------------ +# sub do_start +# +# Recreates the IPTables chains and the IPSets from the saved values +#------------------------------------------------------------------------------ + +sub do_start() +{ + log_message LOG_NOTICE, "Starting IP Blacklists"; + + foreach my $list ( sort keys %sources ) + { + delete_list( $list ) if (exists $chains{$list}); # Make sure OK to start + + if ((-e "$savedir/$list.conf") and ($red_iface)) + { + log_message LOG_INFO, "Restoring blacklist $list"; + system( "$ipset restore -f $savedir/$list.conf" ); # Can't use the ipset + # function to do this + + create_list( $list ); + } + } +} + + +#------------------------------------------------------------------------------ +# sub do_delete +# +# Deletes the IPTables chains, the IPSets and the saved values. +#------------------------------------------------------------------------------ + +sub do_delete() +{ + # Get the list of current ipsets + + get_ipsets(); + + log_message LOG_NOTICE, "Deleting IP Blacklists"; + + foreach my $source ( sort keys %sources ) + { + if (exists $chains{$source}) + { + delete_list( $source ); + } + + if (-e "$savedir/$source.conf") + { + unlink "$savedir/$source.conf"; + } + } + + %modified = (); + $update_status = 1; +} + + +#------------------------------------------------------------------------------ +# sub do_update +# +# Updates all the blacklists. +# Creates or deletes the blacklist firewall rules as necessary and checks for +# updates to the blacklists. Each blacklist has its own minimum elapsed time +# between updates, which is specified in the sources file, so the time of each +# check is stored. +#------------------------------------------------------------------------------ + +sub do_update() +{ + return unless ($red_iface); + + # Get the list of current ipsets + + get_ipsets(); + + # Check sources + + debug 1, "Checking blacklist sources"; + + LIST: + foreach my $list ( sort keys %sources ) + { + my @new_blacklist = (); + my $name = $sources{$list}{'name'}; + my $last_checked = $checked{$list} || 0; + my $failures = $checked{"${list}_failures"} || 0; + my $enabled = 0; + + if (exists $settings{$list}) + { + $enabled = $settings{$list} eq 'on'; + } + + if ($enabled and is_connected()) + { + debug 1, "Checking blacklist source: $name"; + + # Calculate the per list rate + + my $rate = get_rate_seconds( $sources{$list}{'rate'} ); + + # Has enough time passed since the last time we checked the list? + # Ignore the limit if the last download failed + + if (($last_checked + $rate) < (time() + $margin) or + ($failures > 0 and $failures < $max_dl_fails)) + { + my $type = 'hash:ip'; + + download_list( $list, \@new_blacklist, \$type ); + + next LIST unless (@new_blacklist); + + if (not exists $chains{$list}) + { + # Doesn't currently exist: Create it. + + create_ipset( $list, $type, scalar @new_blacklist ); + create_list( $list ); + } + + update_list( $list, \@new_blacklist, $type ); + } + } + elsif (exists $chains{$list}) + { + # Exists, but not enabled: Delete it. + + delete_list( $list ); + + # Delete the save file + # Don't delete the checked time from the status, in case the list is + # re-enabled quickly - don't want to exceed maximum allowed download + # rate. + + unlink "$savedir/$list.conf" if (-e "$savedir/$list.conf"); + + delete $modified{$list} if (exists $modified{$list}); + delete $checked{"${list}_failures"} if (exists $checked{"${list}_failures"}); + $update_status = 1; + } + } + + # Check for any lists that don't exist any more + + foreach my $list (keys %modified) + { + next if (exists $sources{$list}); + + delete_list( $list ); + + # Delete the save file + + unlink "$savedir/$list.conf" if (-e "$savedir/$list.conf"); + + # Delete from the status + + delete $modified{$list} if (exists $modified{$list}); + $update_status = 1; + } + + foreach my $list (keys %checked) + { + next if ($list =~ m/_failures/); + next if (exists $sources{$list}); + + delete $checked{$list}; + delete $checked{"${list}_failures"}; + delete $settings{$list} if (exists $settings{$list}); + $update_status = 1; + } +} + + +#------------------------------------------------------------------------------ +# sub get_rate_seconds( text ) +# +# Converts a check rate into seconds. A sanity check is made on the coverted +# value. +# +# Parameters: +# text The value to convert in the form nnnu, where nnn is a number and u +# is either m (minutes), h (hours) or d (days). Hours is assumed if +# not specified and everything after the first letter is ignored. +#------------------------------------------------------------------------------ + +sub get_rate_seconds( $ ) +{ + my ($text) = @_; + + my ($value, $unit) = (uc $text) =~ m/(\d+)([DHM]?)/; + + if ($unit eq 'D') # Days + { + $value *= 60 * 60 * 24; + } + elsif ($unit eq 'M') # Minutes + { + $value *= 60; + } + else # Everything else - assume hours + { + $value *= 60 * 60; + } + + # Sanity check - limit to range 5 min .. 1 week + + # d h m s + $value = 5 * 60 if ($value < 5 * 60); + $value = 7 * 24 * 60 * 60 if ($value > 7 * 24 * 60 * 60); + + return $value; +} + + +#------------------------------------------------------------------------------ +# sub is_connected() +# +# Checks that the system is connected to the internet. +# +# This looks for a file created by IPFire when connected to the internet +#------------------------------------------------------------------------------ + +sub is_connected() +{ + return (-e $active); +} + + +#------------------------------------------------------------------------------ +# sub create_list( list ) +# +# Creates a new IPTables chain for a blacklist source. +# The set must be created before calling this function. Two rules are added to +# the chain: +# (optional) 1 Log the packet +# 2 Drop the packet +# +# The log rule is only added when logging is enabled by the WUI. +# +# Rules are then added to the BLACKLISTIN and BLACKLISTOUT chains that check +# the packet's IP address against the IPSet and then jump to the newly created +# chain. +# +# Parameters: +# list The name of the blacklist +#------------------------------------------------------------------------------ + +sub create_list( $ ) +{ + my ($list) = @_; + + log_message LOG_INFO, "Create IPTables chains for blacklist $list"; + + # Create new chain in filter table + + iptables( "-N ${list}_DROP" ) == 0 or + ( abort "Could not create IPTables chain ${list}_DROP", return ); + + # Add the logging and drop rules + + if ($settings{'LOGGING'} eq 'on') + { + iptables( "-A ${list}_DROP -j LOG -m limit --limit 10/second --log-prefix 'BLKLST_$list'" ) == 0 or + ( abort "Could not create IPTables chain $list LOG rule", return ); + } + + iptables( "-A ${list}_DROP -j DROP" ) == 0 or + ( abort "Could not create IPTables chain $list drop rule", return ); + + # Add the rules to check against the set + + iptables( "-A BLACKLISTIN -p ALL -i $red_iface -m set --match-set $list src -j ${list}_DROP" ); + iptables( "-A BLACKLISTOUT -p ALL -o $red_iface -m set --match-set $list dst -j ${list}_DROP" ); +} + + +#------------------------------------------------------------------------------ +# sub delete_list( $list ) +# +# Deletes an IPTables chain when a blacklist source is disabled. Also flushes +# and destroys the IPSet. +# +# Parameters: +# list The name of the blacklist +#------------------------------------------------------------------------------ + +sub delete_list( $ ) +{ + my ($list) = @_; + + log_message LOG_INFO, "Delete IPTables chains for blacklist $list"; + + # Remove the blacklist chains from the main INPUT and OUTPUT chains + + iptables( "-D BLACKLISTIN -p ALL -i $red_iface -m set --match-set $list src -j ${list}_DROP" ); + iptables( "-D BLACKLISTOUT -p ALL -o $red_iface -m set --match-set $list dst -j ${list}_DROP" ); + + # Flush and delete the chain + + iptables( "-F ${list}_DROP" ); + iptables( "-X ${list}_DROP" ); + + # Flush and delete the set + + ipset( "flush $list" ); + ipset( "destroy $list" ); +} + + +#------------------------------------------------------------------------------ +# sub download_list( list, ref_list, ref_type ) +# +# Downloads the IP Addresses for a blacklist. +# +# Once downloaded the list is parsed to get the IP addresses and/or networks. +# +# Parameters: +# list The name of the blacklist +# ref_list A reference to an array to store the downloaded blacklist +# ref_type A reference to store the type of the blacklist +#------------------------------------------------------------------------------ + +sub download_list( $$$ ) +{ + my ($list, $new_blacklist, $type) = @_; + + $checked{$list} = time(); # Record that the list has been checked + $update_status = 1; + + # Check the parser for the blacklist + + if (not exists $parsers{ $sources{$list}{'parser'} }) + { + log_message LOG_ERR, "Can't find parser $sources{$list}{'parser'} for $list blacklist"; + return; + } + + # Add alternative download mechanisms here + + download_check_header_time( $list, $new_blacklist, $type ); +} + + +#------------------------------------------------------------------------------ +# sub download_check_header_time( list, ref_list, ref_type ) +# +# Updates the IP Addresses for a blacklist. The If-Modified-Since header is +# specified in the request so that only updated lists are downloaded (providing +# that the server supports this functionality). +# +# Once downloaded the list is parsed to get the IP addresses and/or networks. +# +# Parameters: +# list The name of the blacklist +# ref_list A reference to an array to store the downloaded blacklist +# ref_type A reference to store the type of the blacklist +# +# Returns: +# The list type: 'hash:ip' or 'hash:net' +#------------------------------------------------------------------------------ + +sub download_check_header_time( $$$ ) +{ + my ($list, $new_blacklist, $type) = @_; + my $found_ip = 0; + my $found_net = 0; + + # Get the parser for the blacklist + + my $parser = $parsers{ $sources{$list}{'parser'} }; + + debug 1, "Checking for blacklist $list updates with LWP"; + + # Create a user agent for downloading the blacklist + # Limit the download size for safety + + my $ua = LWP::UserAgent->new( max_size => $max_dl_bytes ); + + # Get the Proxy settings + + if ($proxy_settings{'UPSTREAM_PROXY'}) + { + if ($proxy_settings{'UPSTREAM_USER'}) + { + $ua->proxy( [["http", "https"] => "http://$proxy_settings{'UPSTREAM_USER'}:$proxy_settings{'UPSTREAM_PASSWORD'}\@$proxy_settings{'UPSTREAM_PROXY'}/"] ); + } + else + { + $ua->proxy( [["http", "https"] => "http://$proxy_settings{'UPSTREAM_PROXY'}/"] ); + } + } + + # Get the last modified time + + my $modified = gmtime( $modified{$list} || 0 ); + + # Download the blacklist + + my $response = $ua->get( $sources{$list}{'url'}, 'If-Modified-Since' => $modified ); + + if (not $response->is_success) + { + if ($response->code == 304) + { + # Not an error - list has not been modified + debug 1, "Blacklist $list not modified"; + + return; + } + + log_message LOG_WARNING, "Failed to download $list blacklist $sources{$list}{'url'}: ". $response->status_line; + $checked{"${list}_failures"}++; + + return; + } + + $modified{$list} = $response->last_modified; + $checked{"${list}_failures"} = 0; + + # Parse the downloaded list, checking if it's a list of addresses or nets + + foreach my $line (split /[\r\n]+/, $response->content) + { + chomp $line; + + my $address = &$parser( $line ); + + next unless ($address and $address =~ m/\d+\.\d+\.\d+\.\d+/); + + if ($address =~ m|/32|) + { + $address =~ s|/32||; + $found_ip = 1; + } + elsif ($address =~ m|/\d+|) + { + $found_net = 1; + } + else + { + $found_ip = 1; + } + + push @{ $new_blacklist }, $address; + } + + if ($found_net and $found_ip) + { + # Convert mixed addresses and networks to all networks + + foreach my $address (@{ $new_blacklist }) + { + $address .= '/32' unless ($address =~ m|/\d+|); + } + + $found_ip = 0; + } + + $$type = $found_net ? 'hash:net' : 'hash:ip'; +} + + +#------------------------------------------------------------------------------ +# sub read_ipset( list, old, type, maxelem ) +# +# Reads the existing contents and type of the set. +# +# Parameters: +# list The name of the blacklist +# old Reference to array to contain blacklist +# type Reference to type +# maxelem Reference to maximum number of elements +#------------------------------------------------------------------------------ + +sub read_ipset( $$$$ ) +{ + my ($list, $old, $type, $maxelem) = @_; + my $found_net = 0; + my $found_ip = 0; + + debug 2, "Reading existing ipset for blacklist $list"; + + foreach my $line (qx/$ipset list $list/) + { + if ($line =~ m|Header:.*maxelem (\d+)|) + { + $$maxelem = $1; + next; + } + + next unless ($line =~ m|(\d+\.\d+\.\d+\.\d+(?:/\d+)?)|); + + my $address = $1; + + if ($address =~ m|/32|) + { + $found_ip = 1; + $address =~ s|/32$||; + } + elsif ($address =~ m|/\d+$|) + { + $found_net = 1; + } + else + { + $found_ip = 1; + } + + $$old{$address} = 1; + } + + if ($found_ip and $found_net) + { + # Convert mixed addresses and networks to all networks + + my @ads_list = keys %{ $old }; + + foreach my $address (@ads_list) + { + unless ($address =~ m|/\d+|) + { + delete $$old{$address}; + $$old{"$address/32"} = 1; + } + } + + $found_ip = 0; + } + + $$type = $found_net ? 'hash:net' : 'hash:ip'; +} + + +#------------------------------------------------------------------------------ +# sub update_list( list, new, new_type ) +# +# Updates the IP Addresses for a blacklist +# +# The new list is compared to the existing list and new entries added or old +# entries deleted as necessary. If the list type ('hash:ip' or 'hash:net') has +# changed then the IPSet is deleted and re-created with the new type. +# +# Parameters: +# list The name of the blacklist +# new Reference to array of new blacklist entries +# new_type The type of the updated list (hash:ip or hash:net) +#------------------------------------------------------------------------------ + +sub update_list( $$$ ) +{ + my ($list, $new, $new_type) = @_; + my %old; + my $old_type; + my $changes = 0; + my $maxelem = 0; + + debug 1, "Checking for $list blacklist update from $sources{$list}{'url'}"; + + if (exists $chains{$list} ) + { + my $recreate_ipset = 0; + + read_ipset( $list, \%old, \$old_type, \$maxelem ); + + # Check the IPSet type hasn't changed + + if ($new_type ne $old_type) + { + log_message LOG_NOTICE, "Blacklist $list changed type from $old_type to $new_type"; + $recreate_ipset = 1; + } + + if ($max_size_fraction * $maxelem < scalar @{ $new } ) + { + log_message LOG_NOTICE, "Blacklist $list changed size from $maxelem"; + $recreate_ipset = 1; + } + + if ($recreate_ipset) + { + # Change the IPSet type and/or size. This requires removing references + # to it first. We could delete and then create the chain, but doing it + # like this keeps the statistics. + + # Remove the IPSet from the IPTables chains + + iptables( "-D 'BLACKLISTIN' -p ALL -i $red_iface -m set --match-set $list src -j ${list}_DROP" ) == 0 or + log_message LOG_ERR, "Could not remove ${list} from BLACKLISTIN chain"; + + iptables( "-D 'BLACKLISTOUT' -p ALL -o $red_iface -m set --match-set $list dst -j ${list}_DROP" ) == 0 or + log_message LOG_ERR, "Could not remove ${list} from BLACKLISTOUT chain"; + + # Flush and delete the old set + + ipset( "flush $list" ); + ipset( "destroy $list" ); + + %old = (); # Since we've deleted the old set it can't have any entries. + + # Create the new ipset + + create_ipset( $list, $new_type, scalar @{ $new } ); + + # Add the rules to check against the set + + iptables( "-A 'BLACKLISTIN' -p ALL -i $red_iface -m set --match-set $list src -j ${list}_DROP" ) == 0 or + log_message LOG_ERR, "Could not add IPSet $list to BLACKLISTIN chain"; + + iptables( "-A 'BLACKLISTOUT' -p ALL -o $red_iface -m set --match-set $list dst -j ${list}_DROP" ) == 0 or + log_message LOG_ERR, "Could not add IPSet $list to BLACKLISTOUT chain"; + } + } + + # Process the blacklist + + foreach my $address ( @{ $new } ) + { + # We've got an address. Add to IPSet if it's new + + if (exists $old{$address}) + { + delete $old{$address}; # Not new - don't delete from chain later + } + else + { + ipset( "add $list $address -exist" ); # New - add it + + $changes++; + } + + debug 3, "Add net $address to blacklist $list"; + } + + # Delete old entries that aren't needed any more + + debug 2, "Removing deleted rules from IPTables chain for blacklist $list"; + + foreach my $address ( keys %old ) + { + ipset( "del $list $address" ); + + $changes++; + + debug 3, "Delete old net $address from blacklist $list"; + } + + log_message LOG_INFO, "Updated $list blacklist with $changes changes"; + + # Save the blacklist for the next reboot + + mkdir "$savedir" unless (-d "$savedir" ); + + ipset( "save $list -file $savedir/$list.conf" ) if ($changes > 0); + + stop_ipset(); +} + + +#------------------------------------------------------------------------------ +# sub get_ipsets( ) +# +# Gets a list of the current IPSets +#------------------------------------------------------------------------------ + +sub get_ipsets( ) +{ + debug 1, "Reading list of existing ipsets"; + + my @sets = qx($ipset -n list); + + # Parse the tables + + foreach my $line (@sets) + { + chomp $line; + + next unless ($line); + + $chains{$line} = 1; + } +} + + +#------------------------------------------------------------------------------ +# sub create_ipset( list, type, size ) +# +# Creates a new IPSet. The current size of the set is determined by taking the +# next power of two greater than the number of entries; the maximum size is set +# to double this, subject to a minimum size. This allows for future expansion. +# +# Parameters: +# list The name of the blacklist +# type The type of the blacklist (hash:ip or hash:net) +# size The number of entries in the lsit +#------------------------------------------------------------------------------ + +sub create_ipset( $$$ ) +{ + my ($list, $type, $size) = @_; + + my $hashsize = 1; + $hashsize <<= 1 while ($hashsize < $size); + my $maxsize = $hashsize * 2; + $maxsize = $min_ipset_entries if ($maxsize < $min_ipset_entries); + + # Create the new ipset + ipset( "create $list $type hashsize $hashsize maxelem $maxsize" ); + stop_ipset(); # Need to do this to action the IPSet commands +} + + +#------------------------------------------------------------------------------ +# sub enable_logging() +# +# Enable logging of packets dropped by IP Blacklist rules. +# This adds a rule to log the packet to each lists' IPTables chain. +#------------------------------------------------------------------------------ + +sub enable_logging() +{ + get_ipsets(); + + log_message LOG_NOTICE, "Enabling IP Blacklist logging"; + + foreach my $list ( sort keys %sources ) + { + if (exists $chains{$list}) + { + iptables( "-I ${list}_DROP 1 -j LOG -m limit --limit 10/second --log-prefix 'BLKLST_$list'" ); + } + } +} + + +#------------------------------------------------------------------------------ +# sub disable_logging() +# +# Disable logging of packets dropped by IP Blacklist rules. +# This deletes a rule to log the packet from each lists' IPTables chain. +#------------------------------------------------------------------------------ + +sub disable_logging() +{ + get_ipsets(); + + log_message LOG_NOTICE, "Disabling IP Blacklist logging"; + + foreach my $list ( sort keys %sources ) + { + if (exists $chains{$list}) + { + iptables( "-D ${list}_DROP -j LOG -m limit --limit 10/second --log-prefix 'BLKLST_$list'" ); + } + } +} + + +#------------------------------------------------------------------------------ +# sub enable_updates() +# +# Adds a command to the fcrontab to run the update hourly. +# If there is a command already in the fcrontab to do this it will be +# uncommented, otherwise a new line is added. +# +# The update is executed at an offset from the hour so that all the users don't +# try to download the updates at exactly the same time - the blacklists are +# provided free, so it's good manners to spread the load on the servers. The +# offset is initialised to a random number that avoids running on the hour +# (when a lot of other things happen), and every fifteen minutes thereafter. +#------------------------------------------------------------------------------ + +sub enable_updates() +{ + my @lines = qx/$fcrontab -l/; + my $found = 0; + + # Check for an existing fcrontab entry. + + foreach my $line (@lines) + { + if ($line =~ m|/usr/local/bin/ipblacklist|) + { + return if ($line !~ m/^#/); # Already enabled - do nothing + + # Already in fcrontab - uncomment the line + + $line =~ s/^#+//; + $found = 1; + log_message LOG_INFO, "Enable IP Address Blacklist update in crontab"; + last; + } + } + + if (not $found) + { + # Add a new entry to fcrontab + + my $start = int( rand(13) ) + 1; + + my $times = $start; + + for (my $offset = $times+15 ; $offset < 60 ; $offset += 15) + { + $times .= ",$offset"; + } + + push @lines, "\n"; + push @lines, "# IP Blacklist update\n"; + push @lines, "\%nice(1) $times * * * * /usr/local/bin/ipblacklist\n"; + log_message LOG_INFO, "Add IP Address Blacklist update to crontab"; + } + + open FCRONTAB, "| $fcrontab -" or (abort "Can't open pipe to write fcrontab: $!", return); + print FCRONTAB @lines; + close FCRONTAB; +} + + +#------------------------------------------------------------------------------ +# sub disable_updates() +# +# Comments out the entry in the fcrontab that runs the updates. +#------------------------------------------------------------------------------ + +sub disable_updates() +{ + my @lines = qx/$fcrontab -l/; + my $found = 0; + + foreach my $line (@lines) + { + if ($line =~ m|/usr/local/bin/ipblacklist|) + { + return if ($line =~ m/^#/); # Already disabled - do nothing + + # In fcrontab - comment the line + + $line =~ s/^#*/#/; + $found = 1; + log_message LOG_INFO, "Disable IP Address Blacklist updates"; + last; + } + } + + return unless ($found); # Don't update crontab unnecessarily + + open FCRONTAB, "| $fcrontab -" or (abort "Can't open pipe to write fcrontab: $!", return); + print FCRONTAB @lines; + close FCRONTAB; +} + + +#------------------------------------------------------------------------------ +# sub parse_ip_or_net_list( line ) +# +# Parses an input line, looking for lines starting with an IP Address or +# Network specification. +# +# Parameters: +# line The line to parse +# +# Returns: +# Either an IP Address or a null string +#------------------------------------------------------------------------------ + +sub parse_ip_or_net_list( $ ) +{ + my ($line) = @_; + + $line =~ m|^(\d+\.\d+\.\d+\.\d+(?:/\d+)?)|; + + return $1; +} + + +#------------------------------------------------------------------------------ +# sub parse_dshield( line ) +# +# Parses an input line removing comments. +# +# The format is: +# Start Addrs End Addrs Netmask Nb Attacks Network Name Country email +# We're only interested in the start address and netmask. +# +# Parameters: +# line The line to parse +# +# Returns: +# Either and IP Address or a null string +#------------------------------------------------------------------------------ + +sub parse_dshield( $ ) +{ + my ($line) = @_; + + return "" if ($line =~ m/^\s*#/); + + $line =~ s/#.*$//; + + # |Start addrs | |End Addrs | |Mask + $line =~ m|(\d+\.\d+\.\d+\.\d+(?:/\d+)?)\s+\d+\.\d+\.\d+\.\d+(?:/\d+)?\s+(\d+)|; + + return unless ($1); + return "$1/32" unless ($2); + + return "$1/$2"; +} + + +#------------------------------------------------------------------------------ +# sub iptables( cmd ) +# +# Executes an IPTables command, waiting for the internal lock to ensure only +# one change is made at a time. +# +# Parameters: +# cmd The command to execute +# +# Returns: +# Status of command +#------------------------------------------------------------------------------ + +sub iptables( $ ) +{ + my ($cmd) = @_; + + return system( "$iptables $cmd" ); +} + + +#------------------------------------------------------------------------------ +# sub ipset( cmd ) +# +# Executes an IPSet command. The command is piped to a sub-process running +# ipset, rather than exected separately. This saves the overhead of starting a +# new process for each command. The sub-process is started if it's not already +# running. +# +# Note that the pipe is buffered so commands are not necessarily executed +# immediately. Use ipset_stop() to force commands to be executed. This should +# be done before relying on anything that the ipset commands do, for example +# before referencing the IPSet in an IPTables command. +# +# Parameters: +# cmd The command to execute +#------------------------------------------------------------------------------ + +sub ipset( $ ) +{ + my ($cmd) = @_; + + if (not $ipset_running) + { + local $SIG{PIPE} = 'IGNORE'; + open IPSET, "|-", $ipset, "restore" or die "Can't start ipset: $!"; + $ipset_running = 1; + } + + print IPSET "$cmd\n"; +} + + +#------------------------------------------------------------------------------ +# sub stop_ipset( ) +# +# Stops the ipset sub-process. +# This causes any pending ipset commands to be executed. +#------------------------------------------------------------------------------ + +sub stop_ipset( ) +{ + if ($ipset_running) + { + close IPSET or abort "ipset process died: $! $?"; + $ipset_running = 0; + } +} + + +#------------------------------------------------------------------------------ +# sub abort( message, parameters... ) +# +# Used when aborting the current activity, printing out an error message. +# +# Parameters: +# message Message to be printed +#------------------------------------------------------------------------------ + +sub abort( $ ) +{ + my ($message) = @_; + + log_message( LOG_ERR, $message ); + carp $message; +} + + +#------------------------------------------------------------------------------ +# sub log_message( level, message ) +# +# Logs a message. If the script is run from a terminal messages are also +# output on STDOUT. +# +# Parameters: +# level Severity of message +# message Message to be logged +#------------------------------------------------------------------------------ + +sub log_message( $$ ) +{ + my ($level, $message) = @_; + + print "($level) $message\n" if (-t STDIN); + syslog( $level, $message ); +} + + +#------------------------------------------------------------------------------ +# sub debug( level, message ) +# +# Optionally logs a debug message. If the script is run from a terminal, level +# 1 debug messages are output regardless of the debug setting. +# +# Parameters: +# level Debug level +# message Message to be logged +#------------------------------------------------------------------------------ + +sub debug( $$ ) +{ + my ($level, $message) = @_; + + if (($level <= $settings{'DEBUG'}) or + ($level == 1 and -t STDIN)) + { + log_message LOG_DEBUG, $message; + } +}