#!/usr/bin/perl -w

#
# ferm, a firewall setup program that makes firewall rules easy!
#
# Copyright (C) 2001-2012 Max Kellermann, Auke Kok
#
# Comments, questions, greetings and additions to this program
# may be sent to <ferm@foo-projects.org>
#

# This tool allows you to import an existing firewall configuration
# into ferm.

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

# $Id$

use strict;

use Data::Dumper;

BEGIN {
    # find the main "ferm" program
    my $ferm;
    if ($0 =~ /^(.*)\//) {
        $ferm = "$1/ferm";
    } else {
        $ferm = 'ferm';
    }

    # import its module tables
    require $ferm;

    # delete conflicting symbols
    delete $main::{$_} for qw(merge_keywords parse_option);
}

use vars qw(%aliases);
%aliases = (
    i => 'interface',
    o => 'outerface',
    p => 'protocol',
    d => 'daddr',
    s => 'saddr',
    m => 'match',
    j => 'jump',
    g => 'goto',
);

use vars qw($indent $table $chain @rules $domain $next_domain);

$indent = 0;

sub ferm_escape($) {
    local $_ = shift;
    return $_ unless /[^-\w.:\/]/s or length == 0;
    return "\'$_\'";
}

sub format_array {
    my $a = shift;
    return ferm_escape($a) unless ref $a;
    return ferm_escape($a->[0]) if @$a == 1;
    return '(' . join(' ', map { ferm_escape($_) } @$a) . ')';
}

sub write_line {
    # write a line of tokens, with indent handling

    # don't add space before semicolon
    my $comma = $_[-1] eq ';' ? pop : '';
    # begins with closing curly braces -> decrease indent
    $indent -= 4 if $_[0] =~ /^}/;
    # do print line
    print ' ' x $indent;
    print join(' ', @_);
    print "$comma\n";
    # ends with opening curly braces -> increase indent
    $indent += 4 if $_[-1] =~ /{$/;
}

sub module_match_count {
    my ($module, $rules) = @_;
    my $count = 0;
    foreach (@$rules) {
        last unless $_->{mod}{$module};
        $count++;
    }
    return $count;
}

sub prefix_matches {
    my ($a, $b) = @_;
    return @{$b->{match}} > 0 &&
      (Dumper($a->{match}[0]) eq Dumper($b->{match}[0]));
}

sub prefix_match_count {
    my ($prefix, $rules) = @_;
    my $count = 0;
    foreach (@$rules) {
        last unless prefix_matches($prefix, $_);
        $count++;
    }
    return $count;
}

sub is_merging_array_member {
    my $value = shift;
    return defined $value &&
      ((!ref($value)) or
       ref $value eq 'ARRAY');
}

sub array_matches($$) {
    my ($rule1, $rule2) = @_;
    return if @{$rule1->{match}} == 0 or @{$rule2->{match}} == 0;
    return unless is_merging_array_member($rule1->{match}[0][1]);
    return unless is_merging_array_member($rule2->{match}[0][1]);
    return unless @{$rule2->{match}} > 0;
    return unless $rule1->{match}[0][0] eq $rule2->{match}[0][0];
    my %r1 = %$rule1;
    my %r2 = %$rule2;
    $r1{match} = [ @{$r1{match}} ];
    $r2{match} = [ @{$r2{match}} ];
    shift @{$r1{match}};
    shift @{$r2{match}};
    return Dumper(\%r1) eq Dumper(\%r2);
}

sub array_match_count($\@) {
    my ($first, $rules) = @_;
    return 0 unless @{$first->{match}} > 0;
    my $count = 0;
    foreach (@$rules) {
        last unless array_matches($first, $_);
        $count++;
    }
    return $count;
}

sub optimize {
    my @result;

    # try to find a common prefix and put rules in a block:
    # saddr 1.2.3.4 proto tcp dport ssh ACCEPT;
    # saddr 5.6.7.8 proto tcp dport ssh DROP;
    # ->
    # proto tcp dport ssh {
    #     saddr 1.2.3.4 ACCEPT;
    #     saddr 5.6.7.8 DROP;
    # }
    while (@_ > 0) {
        my $rule = shift;
        if (@{$rule->{match}} > 0) {
            my $match_count = prefix_match_count($rule, \@_);

            if ($match_count > 0) {
                my $match = $rule->{match}[0];
                my @matching = ( $rule, splice(@_, 0, $match_count) );
                map { shift @{$_->{match}} } @matching;

                my @block = optimize(@matching);

                if (@block == 1) {
                    $rule = $block[0];
                    unshift @{$rule->{match}}, $match;
                    push @result, $rule;
                } else {
                    push @result, {
                        match => [ $match ],
                        block => \@block,
                    };
                }
            } else {
                push @result, $rule;
            }
        } else {
            push @result, $rule;
        }
    }

    @_ = @result;
    undef @result;

    # try to combine rules with arrays:
    # saddr 1.2.3.4 proto tcp ACCEPT;
    # saddr 5.6.7.8 proto tcp ACCEPT;
    # ->
    # saddr (1.2.3.4 5.6.7.8) proto tcp ACCEPT;
    while (@_ > 0) {
        my $rule = shift;
        my $match_count = array_match_count($rule, @_);

        if ($match_count > 0) {
            my $option = $rule->{match}[0][0];
            my @matching = ( $rule, splice(@_, 0, $match_count) );
            my @params = map {
                (ref $_ and ref $_ eq 'ARRAY') ? @$_ : $_
            } map {
                $_->{match}[0][1]
            } @matching;

            $rule->{match}[0][1] = \@params;
        }

        push @result, $rule;
    }

    return @result;
}

sub flush_option {
    my ($line, $key, $value) = @_;

    if (ref($value) and ref($value) eq 'pre_negated') {
        push @$line, '!';
        $value = $value->[0];
    }

    push @$line, $key;

    if (ref($value) and ref($value) eq 'negated') {
        push @$line, '!';
        $value = $value->[0];
    }

    if (ref($value) and ref($value) eq 'params') {
        foreach (@$value) {
            push @$line, format_array($_);
        }
    } elsif (defined $value) {
        push @$line, format_array($value);
    }
}

sub flush {
    # optimize and write a list of rules

    my @r = @_ ? @_ : @rules;
    @r = optimize(@r);

    foreach my $rule (@r) {
        my @line;
        # assemble the line, match stuff first, then target parameters
        if (exists $rule->{match}) {
            foreach (@{$rule->{match}}) {
                flush_option(\@line, @$_);
            }
        }

        if (exists $rule->{jump}) {
            if (is_netfilter_core_target($rule->{jump}) ||
                is_netfilter_module_target('ip', $rule->{jump})) {
                push @line, $rule->{jump};
            } else {
                flush_option(\@line, 'jump', $rule->{jump});
            }
        } elsif (exists $rule->{goto}) {
            flush_option(\@line, 'goto', $rule->{goto});
        } elsif (not exists $rule->{block}) {
            push @line, 'NOP';
        }

        if (exists $rule->{target}) {
            foreach (@{$rule->{target}}) {
                flush_option(\@line, @$_);
            }
        }

        if (exists $rule->{block}) {
            # this rule begins a block created in &optimize
            write_line(@line, '{');
            flush(@{$rule->{block}});
            write_line('}');
        } else {
            # just a simple rule
            write_line(@line, ';');
        }
    }
    undef @rules;
}

sub flush_domain() {
    flush;
    write_line '}' if defined $chain;
    write_line '}' if defined $table;
    write_line '}' if defined $domain;

    undef $chain;
    undef $table;
    undef $domain;
}

sub tokenize($) {
    local $_ = shift;
    my @result;
    while (s/^\s*"([^"]*)"//s || s/^\s*(!)// || s/^\s*(\S+)//s) {
        push @result, $1;
    }
    return @result;
}

sub fetch_token($\@) {
    my ($option, $tokens) = @_;
    die "not enough arguments for option '$option' in line $."
      unless @$tokens > 0;
    shift @$tokens;
}

sub fetch_negated(\@) {
    my $tokens = shift;
    @$tokens > 0 && $tokens->[0] eq '!' && shift @$tokens;
}

sub merge_keywords(\%$) {
    my ($rule, $keywords) = @_;
    while (my ($name, $def) = each %$keywords) {
        $rule->{keywords}{$name} = $def;
    }
}

sub parse_def_option($\%$\@) {
    my ($option, $def, $negated, $tokens) = @_;

    my $params = $def->{params};
    my $value;

    $negated = 1 if fetch_negated(@$tokens);

    unless (defined $params) {
        undef $value;
    } elsif (ref $params && ref $params eq 'CODE') {
        # XXX we assume this is ipt_multiport
        $value = [ split /,/, fetch_token($option, @$tokens) ];
    } elsif ($params eq 'm') {
        $value = bless [ fetch_token($option, @$tokens) ], 'multi';
    } elsif ($params =~ /^[a-z]/) {
        die if @$tokens < length($params);

        my @params;
        foreach my $p (split(//, $params)) {
            if ($p eq 's') {
                push @params, shift @$tokens;
            } elsif ($p eq 'c') {
                push @params, [ split /,/, shift @$tokens ];
            } else {
                die;
            }
        }

        $value = @params == 1
          ? $params[0]
            : bless \@params, 'params';
    } elsif ($params == 1) {
        $value = fetch_token($option, @$tokens);
    } else {
        $value = bless [ map {
            fetch_token($option, @$tokens)
        } (1..$params) ], 'multi';
    }

    $value = bless [ $value ], exists $def->{pre_negation} ? 'pre_negated' : 'negated'
      if $negated;

    return $value;
}

sub parse_option(\%$$\@) {
    my ($line, $option, $pre_negated, $tokens) = @_;

    my $cur = $line->{cur};
    die unless defined $cur;

    $option = $aliases{$option} if exists $aliases{$option};
    $option = 'destination-ports' if $option eq 'dports';
    $option = 'source-ports' if $option eq 'sports';

    if ($option eq 'protocol') {
        my %def = ( params => 1 );
        my $value = parse_def_option($option, %def, $pre_negated, @$tokens);
        $line->{proto} = $value;
        push @$cur, [ 'protocol', $value ];

        my $module = netfilter_canonical_protocol($value);
        if (exists $proto_defs{ip}{$module}) {
            merge_keywords(%$line, $proto_defs{ip}{$module}{keywords});
        }

        if ($value =~ /^(?:tcp|udp|udplite|dccp|sctp)$/) {
            my %def = (
                params => 1,
                negation => 1,
               );
            $line->{keywords}{sport} = { name => 'sport', %def };
            $line->{keywords}{dport} = { name => 'dport', %def };
        }
        undef $pre_negated;
    } elsif ($option eq 'match') {
        die unless @$tokens;
        my $param = shift @$tokens;
        $line->{mod}{$param} = 1;
        # we don't need this module if the protocol with the
        # same name is already specified
        push @$cur, [ 'mod', $param ]
          unless exists $line->{proto} and
            ($line->{proto} eq $param or
             $line->{proto} =~ /^(ipv6-icmp|icmpv6)$/s and $param eq 'icmp6');

        my $module = $param eq 'icmp6' ? 'icmpv6' : $param;
        if (exists $match_defs{ip}{$module}) {
            merge_keywords(%$line, $match_defs{ip}{$module}{keywords});
        } elsif (exists $proto_defs{ip}{$module}) {
            merge_keywords(%$line, $proto_defs{ip}{$module}{keywords});
        }

        if ($param =~ /^(?:tcp|udp|udplite|dccp|sctp)$/) {
            my %def = (
                params => 1,
                negation => 1,
               );
            $line->{keywords}{sport} = { name => 'sport', %def };
            $line->{keywords}{dport} = { name => 'dport', %def };
        }
    } elsif (exists $line->{keywords}{$option}) {
        my $def = $line->{keywords}{$option};
        my $value = parse_def_option($option, %$def, $pre_negated, @$tokens);

        if (ref $value and ref $value eq 'multi' and
              @{$line->{cur}} > 0 and $line->{cur}[-1][0] eq $option and
                ref $line->{cur}[-1][1] eq 'multi') {
            # merge multiple "--u32" into a ferm array
            push @{$line->{cur}[-1][1]}, @$value;
            return;
        }

        undef $pre_negated;
        push @{$line->{cur}}, [ $def->{ferm_name} || $def->{name}, $value ];
    } elsif ($option eq 'jump') {
        die unless @$tokens;
        my $target = shift @$tokens;
        # store the target in $line->{jump}
        $line->{jump} = $target;
        # what now follows is target parameters; set $cur
        # correctly
        $line->{cur} = $line->{target} = [];

        $line->{keywords} = {};
        merge_keywords(%$line, $target_defs{ip}{$target}{keywords})
          if exists $target_defs{ip}{$target};
    } elsif ($option eq 'goto') {
        die unless @$tokens;
        my $target = shift @$tokens;
        # store the target in $line->{jump}
        $line->{goto} = $target;
    } else {
        die "option '$option' in line $. not understood\n";
    }

    die "option '$option' in line $. cannot be negated\n"
      if $pre_negated;
}

if (grep { $_ eq '-h' || $_ eq '--help' } @ARGV) {
    require Pod::Usage;
    Pod::Usage::pod2usage(-exitstatus => 0,
                          -verbose => 99);
}

if (@ARGV == 0 && -t STDIN) {
    open STDIN, "iptables-save|"
      or die "Failed run to iptables-save: $!";
} elsif (grep { /^-./ } @ARGV) {
    require Pod::Usage;
    Pod::Usage::pod2usage(-exitstatus => 1,
                          -verbose => 99);
}

print "# ferm rules generated by import-ferm\n";
print "# http://ferm.foo-projects.org/\n";

$next_domain = $ENV{FERM_DOMAIN} || 'ip';

my %policies;

while (<>) {
    if (/^(?:#.*)?$/) {
        # empty or comment

        $next_domain = $1 if /^#.*\b(ip|ip6)tables(?:-save)\b/;
    } elsif (/^\*(\w+)$/) {
        # table

        if (keys %policies > 0) {
            while (my ($chain, $policy) = each %policies) {
                write_line('chain', $chain, 'policy', $policy, ';');
            }
            undef %policies;
        }

        unless (defined $domain and $domain eq $next_domain) {
            flush_domain;
            $domain = $next_domain;
            write_line 'domain', $domain, '{';
        }

        write_line('}') if defined $table;
        $table = $1;
        write_line('table', $table, '{');
    } elsif (/^:(\S+)\s+-\s+/) {
        # custom chain
        die unless defined $table;
        write_line("chain $1;");
    } elsif (/^:(\S+)\s+(\w+)\s+/) {
        # built-in chain
        die unless defined $table;
        $policies{$1} = $2;
    } elsif (s/^-A (\S+)\s+//) {
        # a rule
        unless (defined $chain) {
            flush;
            $chain = $1;
            write_line('chain', $chain, '{');
        } elsif ($1 ne $chain) {
            flush;
            write_line('}');
            $chain = $1;
            write_line('chain', $chain, '{');
        }

        if (exists $policies{$chain}) {
            write_line('policy', $policies{$chain}, ';');
            delete $policies{$chain};
        }

        my @tokens = tokenize($_);

        my %line;
        $line{keywords} = {};
        merge_keywords(%line, $match_defs{ip}{''}{keywords});

        # separate 'match' parameters from 'target' parameters; $cur
        # points to the current position
        $line{cur} = $line{match} = [];
        while (@tokens) {
            local $_ = shift @tokens;
            if (/^-(\w)$/ || /^--(\S+)$/) {
                parse_option(%line, $1, undef, @tokens);
            } elsif ($_ eq '!') {
                die unless @tokens;
                $_ = shift @tokens;
                /^-(\w)$/ || /^--(\S+)$/
                  or die "option expected in line $.\n";
                parse_option(%line, $1, 1, @tokens);
            } else {
                print STDERR "warning: unknown token '$_' in line $.\n";
            }
        }
        delete $line{cur};
        push @rules, \%line;
    } elsif ($_ =~ /^COMMIT/) {
        flush;

        if (defined $chain) {
            write_line('}');
            undef $chain;
        }
    } else {
        print STDERR "line $. was not understood, ignoring it\n";
    }
}

if (keys %policies > 0) {
    while (my ($chain, $policy) = each %policies) {
        write_line('chain', $chain, 'policy', $policy, ';');
    }
}

flush_domain if defined $domain;

die unless $indent == 0;

__END__

=head1 NAME

import-ferm - import existing firewall rules into ferm

=head1 SYNOPSIS

B<import-ferm> > ferm.conf

iptables-save | B<import-ferm> > ferm.conf

B<import-ferm> I<inputfile> > ferm.conf

=head1 DESCRIPTION

This script helps you with porting an existing IPv4 firewall
configuration to ferm.  It reads a file generated with
B<iptables-save>, and tries to suggest a ferm configuration file.

If no input file was specified on the command line, B<import-ferm>
runs F<iptables-save>.

=head1 BUGS

iptables-save older than 1.3 is unable to write valid saves - this is
not a bug in B<import-ferm>.

=cut
