#!/usr/bin/perl -w
#
# shoregen: Generate shorewall configuration for a host from central
# configuration files.
#

#
# (c) Copyright 2004-2006 Paul D. Gear <paul@gear.dyndns.org>
#
# 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 2 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.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA, or go to
# <http://www.gnu.org/copyleft/gpl.html> on the World Wide Web.
#

use strict;

my $VERBOSE = 1;
my $DEBUG = 1;
my $DATE = scalar localtime;
my $HEADER = "#\n# Shorewall %s - constructed by $0 on $DATE\n#\n\n";
my $ret = 0;		# return code to shell

if ($#ARGV != 0) {
    print STDERR "Usage: $0 <hostname>\n";
    exit 1;
}

my $base = ".";
my $host = $ARGV[ 0 ];
my $spool = "$base/SPOOL";
my $dir = "$spool/$host";


#
# Messaging routines for use by the program itself - any errors that are
# generated externally (e.g. file opening problems) are reported using the
# usual perl 'die' or 'warn' functions.
#

sub info
{
    print "$0: @_\n";
}

sub mesg
{
    my $type = shift;
    print STDERR "$0: $type - @_\n";
}

sub warning
{
    mesg "WARNING", @_;
}

sub error
{
    mesg "ERROR", @_;
    ++$ret;
}

sub fatal
{
    mesg "FATAL", @_;
    ++$ret;
    exit $ret;
}


#
# These bits make the files that actually get copied to the target host
#

sub stripfile
{
    open( my $file, $_[ 0 ] ) or die "Can't open $_[ 0 ] for reading: $!";
    my @file;

    for (<$file>) {
	s/\s*#.*$//g;		# remove all comments
	next if m/^\s*$/;	# skip blank lines
	push @file, $_;
    }

    close $file or warn "Can't close $_[ 0 ] after reading: $!";

    return @file;
}


#
# Construct a configuration file given a number of input files
#
sub constructfile
{
    my $confname = shift;
    my $dst = shift;
    my $foundone = 0;

    info "Constructing $confname" if $VERBOSE > 1;

    open( my $DST, ">$dst" ) or die "Can't create $dst: $!";
    printf $DST $HEADER, $confname;

    for my $file (@_) {
	if (-r $file) {
	    $foundone = 1;
	    print $DST "##$file\n" if $DEBUG > 1;
	    print $DST stripfile $file;
	}
    }

    close $DST or warn "Can't close $dst: $!";

    if (!$foundone) {
	warning "\"$confname\" not present.  " .
	    "Existing file on $host will be preserved." if $VERBOSE > 2;
	unlink $dst;
    }
}

#
# main
#

my $fw;			# Firewall zone for this host
my $router;		# Is this host a router?
my @globalzones;	# All known zones
my %globalzones;
my %hostzones;		# zones applicable to this host
my $outfile;		# filename holders
my $conf;		# config file we're processing at present
my %warnban;		# meta-rules/policies


# Change to the base configuration directory
die "Configuration directory $base doesn't exist!" if ! -d $base;
chdir $base or die "Can't change directory to $base: $!";

# Create spool directories if necessary
if (! -d "$spool") {
    mkdir "$spool" or die "Can't create spool directory $spool: $!";
}
if (! -d $dir) {
    mkdir $dir or die "Can't create host spool directory $dir: $!";
}


#
# Construct all the simple config files.
#

# Config files for which the host-specific file is included *first*
my @hostfirstconfigs = qw(
    accounting
    actions
    blacklist
    bogons
    continue
    ecn
    hosts
    interfaces
    maclist
    masq
    nat
    netmap
    proxyarp
    rfc1918
    routestopped
    route_rules
    start
    started
    stop
    stopped
    tcclasses
    tcdevices
    tos
    tunnels
);

# Config files for which the host-specific file is included *last*
my @hostlastconfigs = qw(
    common
    configpath
    init
    initdone
    ipsec
    modules
    params
    providers
    shorewall.conf
    tcrules
);


for my $conf (@hostfirstconfigs) {
    constructfile "$conf", "$dir/$conf", "$conf/$host", "$conf/COMMON";
}

for my $conf (@hostlastconfigs) {
    constructfile "$conf", "$dir/$conf", "$conf/COMMON", "$conf/$host";
}

#
# The remaining config files (policy, rules, zones) are processed uniquely.
#

# Find the firewall name of this host
open( my $infile, "$dir/shorewall.conf" ) or
    die "Can't open $dir/shorewall.conf: $!";

for (<$infile>) {
    if (/^\s*FW=(\S+)/) {
	$fw = $1 unless defined $fw;
    }
    if (/^\s*IP_FORWARDING=(\S+)/) {
	$router = $1 unless defined $router;
    }
}

close $infile;


# The firewall name must be defined
unless (defined $fw) {
    fatal "Can't find firewall name (FW variable) for $host in $dir/shorewall.conf";
}

# Router must be defined
unless (defined $router) {
    fatal "Can't find IP_FORWARDING setting for $host in $dir/shorewall.conf";
}
if ($router =~ m/On|Yes/i) {
    $router = 1;
}
else {
    $router = 0;
}
print "fw=$fw, router=$router\n" if $DEBUG > 3;

# Find all valid zones
unless (-r "zones") {
    fatal "You must provide a global zone file";
}


for (stripfile "zones") {
    chomp;
    my ($zone, $details) = split /\s+/, $_, 2;
    push @globalzones, $zone;
    $globalzones{ $zone } = $details;
}

#
# Work out which zones apply to this host from the combination of hosts &
# interfaces.  The first field in both files is the zone name, and the
# second (minus any trailing ips) is the interface, which we save as well
# for later reference.
#

for my $infile ("$dir/hosts", "$dir/interfaces") {
    if (-r $infile) {
	for (stripfile $infile) {
	    chomp;
	    my @F = split;
	    next if $#F < 0;
	    next if $F[ 0 ] eq "-";
	    my @IF = split /:/, $F[ 0 ]; # strip off parent zone, if present
	    $hostzones{ $IF[ 0 ] } = 1;
	}
    }
}

$conf = "zones";

#
# Create the zones file from the intersection of the above - note the order
# from the original zone file must be preserved, hence the need for the
# array as well as the hash.
#

open( $outfile, ">$dir/$conf" ) or
    die "Can't open $dir/$conf for writing: $!";

printf $outfile $HEADER, "$conf";
my %tmpzones = %hostzones;		# Take a copy of all the zones,

for my $zone (@globalzones) {
    if (exists $tmpzones{ $zone }) {
	print $outfile "$zone	$globalzones{ $zone }\n";
	delete $tmpzones{ $zone };	# deleting those found as we go along.
    }
}

close $outfile or warn "Can't close $dir/$conf after writing: $!";

for my $zone (sort keys %tmpzones) {	# Warn if we've got any zones left now.
    #next if $zone eq "-";
    warning "No entry for $zone in global zones file - ignored";
}
undef %tmpzones;


my @tmp = sort keys %hostzones;
info "FW zone for $host: $fw" if $VERBOSE > 0;
info "Other zones for $host: @tmp" if $VERBOSE > 0;

#
# Add 'all' as a valid source or destination.  Added here so it doesn't get
# checked in %tmpzones check above.  Also add firewall itself.  (The
# numbers are not important as long as they are non-zero.)
#

$hostzones{"all"} = 1;
$hostzones{$fw} = 1;

#
# Create the policy file, including only the applicable zones.
#

$conf = "policy";
if (! -r $conf) {
    fatal "You must provide a global \"$conf\" file";
}

open( $outfile, ">$dir/$conf" ) or
    die "Can't open $dir/$conf for writing: $!";
printf $outfile $HEADER, "$conf";

for (stripfile $conf) {
    chomp;

    my ($src, $dst, $pol, $rest) = split /\s+/, $_, 4;

    print "$src, $dst, $pol, $rest\n" if $DEBUG > 3;

    # Both source and destination zones must be valid on this host for this
    # policy to apply.
    next unless defined $hostzones{$src} and defined $hostzones{$dst};

    # Source and destination zones must be on different interfaces as well,
    # except for the case of all2all.
    #next if ($hostzones{$src} eq $hostzones{$dst} && $src ne "all");

    # Save WARN & BAN details for later rules processing
    if ($pol eq "WARN"  or  $pol eq "BAN") {
	if (exists $warnban{$src}{$dst}) {
	    error "Duplicate WARN/BAN rule: $src,$dst,$pol - possible typo?";
	}
	$warnban{$src}{$dst} = $pol;
	next;
    }

    printf $outfile "%s\n", $_;
}
close $outfile or warn "Can't close $dir/$conf for writing: $!";


#
# Create the rules file, only including the applicable zones and taking
# into account any WARN or BAN policies.
#

$conf = "rules";
if (! -r $conf) {
    fatal "You must provide a global \"$conf\" file";
}

open( $outfile, ">$dir/$conf" ) or
    die "Can't open $dir/$conf for writing: $!";
printf $outfile $HEADER, "$conf";

for my $infile ("$conf.COMMON", "$conf.$host", "$conf") {
    next unless -r $infile;
    for (stripfile $infile) {
	chomp;

	my ($act, $src, $dst, $rest) = split /\s+/, $_, 4;

	$act =~ s/:.*//;	# strip off logging directives
	$src =~ s/:.*//;	# strip off host & port specifiers
	$dst =~ s/:.*//;	# strip off host & port specifiers

	print "$act, $src, $dst, $rest\n" if $DEBUG > 3;

	# Both source and destination zones must be valid on this host
	# for this rule to apply.
	next unless defined $hostzones{$src} and defined $hostzones{$dst};

	# If host is not a router, either the source or destination zone
	# must be the firewall itself.
	if (!$router) {
	    next unless $src eq $fw
		     or $dst eq $fw
		     or $src eq "all"
		     or $dst eq "all";
	}

	# Save additional WARN/BAN rules
	if ($act eq "WARN"  or  $act eq "BAN") {
	    if (exists $warnban{$src}{$dst}) {
		error "Duplicate WARN/BAN rule: $src,$dst,$act - possible typo?";
	    }
	    $warnban{$src}{$dst} = $act;
	    next;
	}

	# Check against WARN/BAN rules
	if (exists $warnban{$src}{$dst} && $act =~ /^(ACCEPT|Allow|DNAT)/) {
	    if ($warnban{$src}{$dst} eq "WARN") {
		warning "Rule contravenes WARN policy:\n\t$_";
	    }
	    else { # $warnban{$src}{$dst} eq "BAN"
		error "Rule contravenes BAN policy (omitted):\n\t$_";
		next;
	    }
	}

	# Mangle DNAT rules if the destination is the local machine
	if ($act =~ /^DNAT/  &&  $dst eq $fw) {
	    $_ =~ s/\bDNAT(-)?/ACCEPT/;	# change rule type
	    $_ =~ s/\b$fw:\S+/$dst/;	# strip trailing server address/port
	}

	printf $outfile "%s\n", $_;
    }
}
close $outfile or warn "Can't close $dir/$conf for writing: $!";


# Finished - return whatever we produced above...
exit $ret;
