#! /usr/bin/ruby
#
# NAME
#   symbiosis-firewall-whitelist - Automatically whitelist IP addresses.
#
# SYNOPSIS
#  symbiosis-firewall-whitelist [ -h | --help ] [-m | --manual]
#       [ -v | --verbose ] [ -x | --no-exec] [ -d | --no-delete ]
#       [ -e | --expire-after <n> ] [ -w | --wtmp-file <file> ]
#       [ -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.
#
#  -e, --expire-after <n>  Number of days after which whitelisted IPs should be
#                          expired. Defaults to 8.
#
#  -w, --wtmp-file <file>  wtmp(5) file to read to find IPs to whitelist.
#                          Defaults to /var/log/wtmp.
#
#
#  -p, --prefix <dir>      Directory where action.d, incoming.d, outgoing.d etc.
#                          are located. Defaults to /etc/symbiosis/firewall.
#
# USAGE
#
# This script is designed to automatically whitelist IP addresses which
# have been used to successfully login via SSH.
#
# It does this by opening the wtmp file, and looking for IP addresses. Once it
# has found some, it records them in /etc/symbiosis/firewall/whitelist.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
#
# Once that directory has been written, symbiosis-firewall(1) is called with
# the reload-whitelist action.
#
# Most of the flags above are passed straight on to symbiosis-firewall(1).
#
# AUTHOR
#
#  Steve Kemp <steve@bytemark.co.uk>
#

#
#  Modules we require
#

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/"
wtmp_file    = "/var/log/wtmp"
delete       = true
execute      = true
force        = false
expire_after = 8

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 ],
         [ '--wtmp-file',  '-w', 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 '--wtmp-file'
      wtmp_file = arg
    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

#
# These requires are here to prevent un-needed dependencies when just making
# manpages.
#
require 'symbiosis/utmp'
require 'symbiosis/utils'
require 'symbiosis/firewall/directory'
require 'symbiosis/firewall/template'
require 'symbiosis/ipaddr'

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

#
# Basics.
#
expired = 0
whitelist_d = File.join(base_dir, "whitelist.d")
syslog = Syslog.open( File.basename($0), Syslog::LOG_NDELAY, Syslog::LOG_USER)

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

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

#
#  Did we update?
#
updated=false

#
# Time we started this run
#
time_now = Time.now

#
# Expiry is measured in days.
#
expire_before = time_now - ( expire_after * ( 24 * 60 * 60 ) )

#
# Check to see when we were last run.
#
stamp_file = '/var/lib/symbiosis/symbiosis-firewall-whitelist.stamp'

if File.exist?(stamp_file)
  last_run = File.stat(stamp_file).mtime
else
  last_run = nil
end

FileUtils.touch(stamp_file)

#
#
# Fetch the IP addresses
#
Symbiosis::Utmp.read(wtmp_file).each do |entry|
  #
  # Only interested in USER_PROCESS types.
  #
  next unless entry['type'] == 7

  #
  # Fetch the time the entry was logged at.
  #
  at = entry['time']

  #
  # Make sure the entry isn't in the future
  #
  next unless at < time_now

  #
  # Make sure the record isn't already expired.
  #
  next unless at > expire_before

  #
  # Fetch the IP
  #
  begin
    ip = Symbiosis::IPAddr.new(entry['ip'].to_s)
  rescue ArgumentError
    #
    # Oops.  Can't interpret the IP.
    #
    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, whitelist_d)
    #
    # Automatically whitelist.
    #
    setting += ".auto"
    value = !!Symbiosis::Utils.get_param(setting, whitelist_d)

    if false == value
      puts "\tAdding whitelist entry" if  $VERBOSE
      syslog.info("adding #{ip} to whitelist")

      value = "all"

    elsif last_run.nil? or at > last_run
      puts "\tUpdating whitelist entry" if  $VERBOSE
      syslog.info("updating #{ip} in whitelist")
  
    else
      next

    end
    #
    # Yes, we're updating.
    #
    updated = true

    Symbiosis::Utils.set_param(setting, value, whitelist_d)
  else
    puts "\tAlready manually whitelisted" if ( $VERBOSE )

  end

end


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

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

  if  File.mtime(entry) < expire_before

    puts "Removing #{entry}" if ( $VERBOSE )
    syslog.info("expiring #{File.basename(entry,".auto")} from whitelist")

    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.
#

