#! /usr/bin/ruby
#
# NAME
#   symbiosis-firewall-blacklist - Automatically blacklist IP addresses.
#
# SYNOPSIS
#  symbiosis-firewall-blacklist [ -h | --help ] [-m | --manual]
#       [ -v | --verbose ] [ -x | --no-exec] [ -d | --no-delete ]
#       [ -a | --block-after <n> ] [ -e | --expire-after <n> ]
#       [ -p | --prefix <dir> ] 
#
# OPTIONS
#  -h, --help              Show a help message, and exit.
#
#  -m, --manual            Show this manual, and exit.
#
#  -v, --verbose           Show verbose errors
#
#  -x, --no-exec           Do not execute the generated firewall rules
#
#  -d, --no-delete         Do not delete the generated script
#
#  -a, --block-after <n>   Number of attempts before an IP address is
#                          blacklisted. Defaults to 25.
#
#  -b, --block-all-after <n>  Number of attempts before an IP address is
#                             blocked from all ports, not just the ports
#                             mentioned in the pattern. Defaults to 100.
#
#  -e, --expire-after <n>  Number of days after which blacklisted IPs should be
#                          expired. Defaults to 2.
#
#  -p, --prefix <dir>      Directory where incoming.d, outgoing.d etc are
#                          located. Defaults to /etc/symbiosis/firewall.
#
# USAGE
#
# This script is designed to automatically blacklist IP addresses which
# have been used to brute force various services running on the machine.
#
# It uses a set of definitions found in $PREFIX/pattern.d/ to match IP
# addresses in log files, and then adds the offending IPs to the blacklist by
# adding files to the directory $PREFIX/blacklist.d.
#
# Each addition is one of the two forms:
#
#   1.2.3.4.auto                The IPv4 address 1.2.3.4
#   2001:123:456:789::|64.auto  The IPv6 range 2001:123:456:789::/64
# 
# It should be noted that IPv6 addresses will be added as entire /64s.
#
# Each file will contain a list of ports, one per line, or simply "all" to
# blacklist all ports.
#
# Once that directory has been written, symbiosis-firewall(1) is called with
# the reload-blacklist action.
#
# Most of the flags above are passed straight on to symbiosis-firewall(1).
#
# SEE ALSO
#
# symbiosis-firewall(1), symbiosis-firewall-whitelist(1)
#
# AUTHOR
#
#   Steve Kemp <steve@bytemark.co.uk>
#

# TODO: fix manpage (above)

require 'getoptlong'
require 'tempfile'
require 'fileutils'
require 'syslog'

#
#  The options set by the command line.
#
help         = false
manual       = false
$VERBOSE     = false
base_dir     = "/etc/symbiosis/firewall/"
delete       = true
execute      = true
force        = false
block_after     = 25
block_all_after = 100
expire_after = 2

opts = GetoptLong.new(
         [ '--help',       '-h', GetoptLong::NO_ARGUMENT ],
         [ '--manual',     '-m', GetoptLong::NO_ARGUMENT ],
         [ '--verbose',    '-v', GetoptLong::NO_ARGUMENT ],
         [ '--no-execute', '-x', GetoptLong::NO_ARGUMENT ],
         [ '--no-delete',  '-d', GetoptLong::NO_ARGUMENT ],
         [ '--force',      '-f', GetoptLong::NO_ARGUMENT ],
         [ '--prefix',     '-p', GetoptLong::REQUIRED_ARGUMENT ],
         [ '--block-after',    '-a', GetoptLong::REQUIRED_ARGUMENT ],
         [ '--block-all-after','-b', GetoptLong::REQUIRED_ARGUMENT ],
         [ '--expire-after', '-e', GetoptLong::REQUIRED_ARGUMENT ]
       )

begin
  opts.each do |opt,arg|
    case opt
    when '--help'
      help = true
    when '--manual'
      manual = true
    when '--verbose'
      $VERBOSE = true
    when '--test'
      test = true
    when '--no-execute'
      execute = false
    when '--no-delete'
      delete = false
    when '--force'
      force = true
    when '--prefix'
      base_dir     = File.expand_path(arg)
    when '--expire-after'
      expire_after = arg.to_i
    when '--block-after'
      block_after = arg.to_i
    end
  end
rescue
  # any errors, show the help
  help = true
end


#
# CAUTION! Here be quality kode.
#
if manual or help
  # Open the file, stripping the shebang line
  lines = File.open(__FILE__){|fh| fh.readlines}[1..-1]

  found_synopsis = false

  lines.each do |line|

    line.chomp!
    break if line.empty?

    if help and !found_synopsis
      found_synopsis = (line =~ /^#\s+SYNOPSIS\s*$/)
      next
    end

    puts line[2..-1].to_s

    break if help and found_synopsis and line =~ /^#\s*$/

  end

  exit 0
end

# Open syslog
syslog = Syslog.open( File.basename($0), Syslog::LOG_NDELAY, Syslog::LOG_USER)

#
# These requires are here to prevent dependency failures when generating manpages.
#
require 'symbiosis/ipaddr'
require 'symbiosis/utils'
require 'symbiosis/firewall/blacklist'
require 'symbiosis/firewall/directory'
require 'symbiosis/firewall/template'
require 'symbiosis/firewall/logtail'
require 'symbiosis/firewall/pattern'

#
# Exit if we've been disabled
#
if %w(disabled.blacklist blacklist.d/disabled).any?{|fn| File.exist?(File.join(base_dir, fn))}
  puts "Firewall blacklist disabled.  Exiting." if $VERBOSE
  exit 0
end

#
# Work out which user we're supposed to create the blacklist directory as.
#
begin
  srv = File.stat("/srv")
  admin_uid = srv.uid
  admin_gid = srv.gid
rescue Errno::ENOENT
  admin_gid = admin_uid = 0
end

expired = 0
blacklist_d = File.join(base_dir, "blacklist.d")

# 
# ensure the directory exists.
#
unless File.directory?( blacklist_d )
  Symbiosis::Utils.mkdir_p(blacklist_d, :user => admin_uid, :group => admin_gid)
end

#
# Fetch the IP addresses
#
blacklist = Symbiosis::Firewall::Blacklist.new
blacklist.block_after = block_after
blacklist.block_all_after = block_all_after
blacklist.base_dir = base_dir

#
#  Did we update?
#
updated=false

#
#  Iterate over each IP
#
blacklist.generate.each do |ip, ports|
  #
  # Make sure we can parse stuff
  #
  begin
    ip = Symbiosis::IPAddr.new(ip)
  rescue ArgumentError => err
    warn "Ignoring #{ip.inspect} because of #{err.to_s}"
    next
  end
  
  #
  # Mask IPv6 to /64s.
  #
  ip = ip.mask(64) if ip.ipv6?

  #
  # Mask IPv4 to /32s.
  #
  ip = ip.mask(32) if ip.ipv4?

  #
  # Only include globally routable IPs.
  #
  # FIXME: Need better IPv6 conditions.
  #
  next if ip.ipv4? and (Symbiosis::IPAddr.new("127.0.0.1/8").include?(ip) or Symbiosis::IPAddr.new("0.0.0.0") == ip )
  next if ip.ipv6? and !Symbiosis::IPAddr.new("2000::/3").include?(ip)

  puts "Found IP address: #{ip}" if ( $VERBOSE )

  setting = ip.to_s.gsub("/","|")

  #
  # Check filename without .auto first.
  #
  if !Symbiosis::Utils.get_param(setting, blacklist_d)
    #
    # Automatically blacklist.
    #
    setting += ".auto"

    old_ports = Symbiosis::Utils.get_param(setting, blacklist_d)

    if old_ports.is_a?(String) or true == old_ports
      
      #
      # Set old_ports to everything if it is just "true" (i.e. an empty file).
      #
      old_ports = "all" if true == old_ports
      old_ports = old_ports.split($/).collect{|pt| pt.strip }

      ports = (ports + old_ports).collect{|pt| pt.nil? ? "all" : pt.to_s }.uniq

      ports = %w(all) if ports.any?{|pt| "all" == pt}
      
      puts "\tUpdating blacklist entry for #{ports.join(",")} ports" if  $VERBOSE 
      syslog.info "updating blacklisted IP #{ip} for #{ports.join(",")} ports"
    else
      #
      # Add to the blacklist.
      #
      puts "\tAdding to blacklist for #{ports.join(",")} ports" if ( $VERBOSE )
      syslog.info "adding #{ip} to blacklist for #{ports.join(",")} ports"
    end

    updated=true

    Symbiosis::Utils.set_param(setting, ports.join("\n"), blacklist_d)

  else
    puts "\tAlready manually blacklisted" if ( $VERBOSE )

  end

end

#
# Expiry is defined in terms of days.
#
expire_before = Time.now - ( expire_after * ( 24 * 60 * 60 ) )

#
#  Now expire old entries.
#
puts "Expiring old blacklist entries" if ( $VERBOSE )

Dir.glob( File.join(blacklist_d,"*.auto" ) ).each do |entry|

  if  File.mtime(entry) < expire_before

    puts "Removing #{entry}" if ( $VERBOSE )
    syslog.info "removing blacklisted IP #{File.basename(entry,".auto")}"
    File.unlink(entry)
    expired += 1
  end

end

puts "Expiring done - removed #{expired} file(s)" if ( $VERBOSE )

#
# Updating the firewall is now done by the inotify cronjob
#
