#!/usr/bin/ruby
# NAME
#  symbiosis-firewall - Symbioisis firewall management
#
# SYNOPSIS
#  symbiosis-firewall [ -h | --help ] [-m | --manual] [ -v | --verbose ]
#       [ -p | --prefix <dir> ] [ -t | --template-d <dir> ] 
#       [ -x | --no-exec] [ -d | --no-delete ] <action>
#
# OPTIONS
#  -h, --help            Show a help message, and exit.
#
#  -m, --manual          Show this manual, and exit.
#
#  -v, --verbose         Show verbose errors
#
#  -p, --prefix <dir>    Directory where action.d, incoming.d, outgoing.d etc
#                        are located. Defaults to /etc/symbiosis/firewall.
#
#  -t, --template-d <dir>  Additional directory to search for templates.
#
#  -x, --no-exec         Do not execute the generated firewall rules
#
#  -d, --no-delete       Do not delete the generated script
#
#  -f, --flush           Set the action to "flush". This will overwrite any
#                        action specified.
#
#  -s, --sleep <n>       Sleep for <n> seconds before executing anything.
#
#  <action>              The action to run. This defaults to "load".
#
# USAGE
#
# This firewall script is designed to be simple to use, while still allowing a
# reasonable level of control over your system. This command is used to update
# the iptables(8) and ip6tables(8) firewalls. It uses a set of directories in
# the prefix directory to define which rules should be applied.
# 
# The script will be executed once it has been generated, and then removed.
# (You may use --no-delete and --no-execute to prevent either action from being
# carried out.)
#
# Usage of the Symbiosis firewall is comprehensively documented in the
# symbiosis-documentation package, as well as on the documentation website.
#
# If this script is run as a user other than root, then the --no-execute and
# --no-delete flags are set.
#
# CONFIGURATION
#
# To configure the firewall which is generated and applied to your server you
# simply need to create files in the directories the script reads:
#
# $PREFIX/incoming.d/   This directory is examined to determine which rules
#                       should be applied to incoming connections.
#
# $PREFIX/outgoing.d/   This directory is examined to determine which rules
#                       should be applied to outgoing connections.
#
# $PREFIX/blacklist.d/  Any file present in this directory is assumed to be the
#                       IP address of a machine you wish to globally prevent
#                       connections from.
#
# $PREFIX/whitelist.d/  Any file present in this directory is assumed to be the
#                       IP address of a machine you wish to globally allow connections from.
#
# $PREFIX/local.d/      Executable shell-scripts in this directory are executed
#                       after the firewall is installed.
#
# For the incoming and outgoing directories you should create files with names
# such as "10-ssh". (The prefix you choose merely determines sorting order.)
#
# The presence of a file named "NN-ssh" will mean that the firewall will
# include rules it knows about for the service "ssh". These rule types may be
# arbitrarily complex, as they are processed via bash(1).
#
# The presence of a rule file will allow access to the named service. For
# example the file "10-ssh" placed in the incoming directory will allow all
# access to port 22. If you wish to restrict access place the hostnames, or IP
# addresses, in the file instead of leaving it empty. This will restrict
# access to/from the named addresses.
#
# If scripts are placed in local.d, then they are executed by run-parts(8), so
# they must follow the naming conventions required by run-parts. Also the exit
# status of the scripts is important. If any script exits with a non-zero
# code, then the whole firewall will have deemed to have failed to load.
#
# ACTIONS AND TEMPLATES
#
# The "actions" are bash(1) scripts, that have been templated using eRuby.
# Each action is found in the action.d directory located inside the prefix
# directory.
#
# The actions that this program comes with are
# 
#   load              Load the firewall.
#   flush             Flush the firewall.
#   reload-blacklist  Update the blacklist chain.
#   reload-whitelist  Update the whitelist chain.
# 
# It is possible to add your own actions to the template as needed.
#
# ADDING ADDITIONAL RULETYPES
#
# For each "rule" type you should simply create two files:
#
#   /usr/local/share/symbiosis/firewall/rule.d/$name.incoming
#   /usr/local/share/symbiosis/firewall/rule.d/$name.outgoing
#
# The contents of these file(s) will be inserted appropriately into the
# generated firewall script.
#
# The magic strings '$SRC' and '$DEST' will be replaced by any IP addresses the
# user has specified in their file - or removed if none are present.
#
# FAILURE 
#
# If the firewall fails to load, an attempt is made to restore the firewall to
# its prior state, using iptables-save(8) and iptables-restore(8), and their
# ip6tables eqivalents. If these commands fail, then the firewall is left
# flushed.
#
# SEE ALSO
#  symbiosis-firewall-whitelist(1), symbiosis-firewall-blacklist(1),
#  iptables(8), ip6tables(8), run-parts(8)
#
# AUTHOR
#  Steve Kemp <steve@bytemark.co.uk>
#  Patrick J Cherry <patrick@bytemark.co.uk>
#


require 'getoptlong'
require 'tempfile'
require 'fileutils'
require 'pp'

def verbose(s)
  puts "symbiosis-firewall: "+s if $VERBOSE
end


#
##
##  Entry point.
##
#######
##############

#
#  Parse the arguments
#
help     = false
manual   = false
$VERBOSE = false
execute  = delete = (Process.uid == 0)
base_dir     = '/etc/symbiosis/firewall'
template_dir = '/usr/share/symbiosis/firewall/rule.d'
address_families = %w(inet inet6)
action = nil
sleep_for = 0

opts = GetoptLong.new(
         [ '--help',       '-h', GetoptLong::NO_ARGUMENT ],
         [ '--manual',     '-m', GetoptLong::NO_ARGUMENT ],
         [ '--verbose',    '-v', GetoptLong::NO_ARGUMENT ],
         [ '--ipv4-only',  '-4', GetoptLong::NO_ARGUMENT ],
         [ '--ipv6-only',  '-6', GetoptLong::NO_ARGUMENT ],
         [ '--no-execute', '-x', GetoptLong::NO_ARGUMENT ],
         [ '--no-delete',  '-d', GetoptLong::NO_ARGUMENT ],
         [ '--flush',      '-f', GetoptLong::NO_ARGUMENT ],
         [ '--prefix',     '-p', GetoptLong::REQUIRED_ARGUMENT ],
         [ '--sleep',      '-s', GetoptLong::REQUIRED_ARGUMENT ],
         [ '--template-d', '-t', 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 "--ipv6-only"
      address_families = %w(inet6)
    when "--ipv4-only"
      address_families = %w(inet)
    when '--prefix'
      base_dir     = File.expand_path(arg)
    when '--template-d'
      template_dir = File.expand_path(arg)
    when '--flush'
      action = "flush"
    when '--sleep'
      sleep_for = arg.to_i
    end
  end
rescue
  # Any exceptions 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

#
# Set the action
#
if action.nil?
  action = ARGV.first || "load"
end

#
# Exit if we've been disabled, but not if we're flushing the firewall.
#
if File.exist?(File.join(base_dir, "disabled")) and action != "flush"
  verbose "Firewall disabled.  Exiting." 
  exit 0 
end

#
# These requires are here to avoid dependency failures when generating manpages.
#
require 'symbiosis/firewall/directory'
require 'symbiosis/firewall/template'
require 'symbiosis/host'
require 'symbiosis/utils'


# DONE parseCommandLineArguments
# TODO sanityCheck
# DONE flushAllRules
# DONE createTemporaryFile
# DONE processWhitelist
# TODO processHostsAllow -- and hosts.deny, I suppose..
# DONE processBlacklist
# DONE findRules
# DONE processRule
# DONE processRuleTemplate
# DONE addRule
# TODO testFirewall
# DONE loadKernelModules
# DONE fixOwnership
# DONE parseServices

#
# Acquire lock
#
lock_fh = nil
lock_fn = "/var/lock/symbiosis-firewall.lock"

begin
  lock_fh = File.open(lock_fn, "w+")
  Symbiosis::Utils.lock(lock_fh)
rescue Errno::ENOLCK => err
  warn "symbiosis-firewall: Failed to acquire lock on #{lock_fn}: #{err.to_s}" if $VERBOSE
  print err.backtrace.join("\n") if $DEBUG
  exit 1
end

#
# Check to see if we've been called as part of a if-up or if-down.
#
if ENV.has_key?("IFACE") and ENV.has_key?("PHASE")
  phase = ENV["PHASE"].to_s
  iface = ENV["IFACE"].to_s
  addrfam = ENV["ADDRFAM"].to_s
  
  if ENV.has_key?("VERBOSITY") and ENV["VERBOSITY"].to_s == "1"
    $VERBOSE = true
  end

  #
  # Don't bother even looking at this if the loopback interface is involved.
  #
  if "lo" == iface
    verbose "symbiosis-firewall: Not configuring firewall for loopback interface #{iface}"
    exit 0
  end

  case phase
    when "post-up"
      action = "load"
    when "pre-down" 
      action = "flush"
    when "post-down" 
      action = "flush"
    else
      warn "symbiosis-firewall: Don't know what to do during if-up/if-down phase #{phase.inspect}"
      exit 1
  end

  #
  # Run for ipv4 / ipv6 as needed
  #
  if %w(inet inet6).include?(addrfam)
    address_families = [addrfam]
  end

  if Symbiosis::Host.primary_interface.nil? 

    if "flush" == action 
      verbose "Flushing firewall for all #{address_families.join(" and ")} interfaces."
    else
      warn "symbiosis-firewall: Not running firewall as no primary interface was found." if $VERBOSE
      exit 0
    end

  elsif Symbiosis::Host.primary_interface.ifname == iface
    verbose "Running #{action} for interface #{iface} (#{address_families.join(" and ")})" if $VERBOSE

  else
    warn "symbiosis-firewall: Not running firewall for secondary interface #{iface.inspect}" if $VERBOSE
    exit 0
  end

end

#
# Sleep a little, for inotify.
#
if sleep_for > 0
  verbose "Sleeping for #{sleep_for} seconds"
  Kernel.sleep sleep_for
end

begin
  include Symbiosis::Firewall

  #
  # Pick out iptables commands as needed.
  #
  iptables_cmds = []
  iptables_cmds << "/sbin/iptables" if address_families.include?("inet")
  iptables_cmds << "/sbin/ip6tables" if  address_families.include?("inet6")

  #
  # Save a copy of iptables before we begin, in case of disaster.
  #
  if address_families.include?("inet")
    iptables_restore = IO.popen('/sbin/iptables-save','r'){|io| io.read}
  else
    iptables_restore = []
  end

  if address_families.include?("inet6")
    ip6tables_restore = IO.popen('/sbin/ip6tables-save','r'){|io| io.read}
  else
    ip6tables_restore = []
  end

  #
  # Load the ports from /etc/services
  #
  Ports.load

  #
  # Set up the template directories.
  #
  Template.directories = %w(/usr/local/share/firewall /usr/local/share/firewall/rule.d /usr/local/share/symbiosis/firewall/rule.d /usr/share/firewall /usr/share/firewall/rule.d /usr/share/symbiosis/firewall/rule.d)
  Template.directories = template_dir unless template_dir.nil?
  Template.address_families = address_families
  iptables_cmds = Template.iptables_cmds

  #
  # Find the script.
  #
  script_path = nil

  #
  # Search for the script in the same directores as the templates, ish.
  #
  Template.directories.collect{|d| d.sub("rule.d","action.d") }.each do |script_dir|
    script_path = File.join(script_dir, "#{action}.sh.erb")
    break if File.exist?(script_path)
  end

  #
  # Make sure we can find the script
  #
  unless ( File.exist?(script_path) )
    warn "symbiosis-firewall: Could not find action script for #{action.inspect}"
    exit 1
  end

  #
  # Create temp file
  #
  tf = Tempfile.new(File.basename($0)+"-")

  #
  # OK ERB it up, baby!
  #
  script = File.read(script_path)
  tf.puts ERB.new(script,0,'%').result(binding)
  tf.close

  #
  # Make the script executable.
  #
  FileUtils.chmod(0755, tf.path)

  #
  # Only execute if we're root.
  #
  if ( execute and ::Process.uid == 0 )

    unless system( tf.path )
      warn "symbiosis-firewall: Firewall script failed."

      #
      # FIXME: This is deliberately hard-coded, although it probably needs to catch errors better.
      #
      iptables_cmds.each do |cmd|
        #
        # Check that the command exists, and we can run it.
        #
        next unless File.executable?(cmd)

        warn "symbiosis-firewall: Flushing #{cmd} rules and chains."

        %w(INPUT FORWARD OUTPUT).each do |chain|
          #
          # Fix policy, flush chain, and allow everything on loopback.
          #
          system(cmd, "-P", chain, "ACCEPT") 
        end
        #
        # Flush everything
        #
        system(cmd,"-F")

        #
        # Delete any old chains
        #
        system(cmd,"-X")
      end

      #
      # Now restore our old tables
      #
      unless iptables_restore.empty?
        warn "symbiosis-firewall: Restoring old iptables rules and chains."
        IO.popen('/sbin/iptables-restore','w'){|io| io.write iptables_restore}
      end
      
      unless ip6tables_restore.empty?
        warn "symbiosis-firewall: Restoring old ip6tables rules and chains."
        IO.popen('/sbin/ip6tables-restore','w'){|io| io.write ip6tables_restore}
      end

      #
      # Leave the script for inspection.
      #
      delete = false

    end
  end

  unless( delete )
    #
    #  TODO: Fix this.  Because we used Tempfile we have to manually
    # copy the firewall to a new name as it is unlinked at process exit.
    #
    new_path = tf.path+"-saved"
    FileUtils.cp tf.path, new_path 
    warn "symbiosis-firewall: Left firewall script in #{new_path} for inspection."
  end

end

#
# Release the lock and close.
#
begin
  Symbiosis::Utils.unlock(lock_fh)
  lock_fh.close unless lock_fh.nil? or lock_fh.closed?
rescue SystemCallError => err
  warn "symbiosis-firewall: Failed to release lock on #{lock_fn}: #{err.to_s}" if $VERBOSE
  puts err.backtrace.join("\n") if $DEBUG
end


exit 0

