merged 1059:1092 from branch ferm-1.3.x: release v1.3.3
[ferm.git] / src / import-ferm
blob9fa90a8b435d408352d99c34e6c47a0051802b26
1 #!/usr/bin/perl
4 # ferm, a firewall setup program that makes firewall rules easy!
6 # Copyright (C) 2001-2007 Auke Kok, Max Kellermann
8 # Comments, questions, greetings and additions to this program
9 # may be sent to <ferm@foo-projects.org>
12 # This tool allows you to import an existing firewall configuration
13 # into ferm.
16 # This program is free software; you can redistribute it and/or modify
17 # it under the terms of the GNU General Public License as published by
18 # the Free Software Foundation; either version 2 of the License, or
19 # (at your option) any later version.
21 # This program is distributed in the hope that it will be useful,
22 # but WITHOUT ANY WARRANTY; without even the implied warranty of
23 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
24 # GNU General Public License for more details.
26 # You should have received a copy of the GNU General Public License
27 # along with this program; if not, write to the Free Software
28 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
31 # $Id$
33 use strict;
35 use Data::Dumper;
37 use vars qw($indent $table $chain @rules $domain $next_domain);
39 sub parse_table {
40 map {
41 /(.+)=(.+)/ ? ( $1 => $2 ) : ( $_ => $_ )
42 } split(/\s+/s, shift);
45 my %p0 = parse_table(<<EOT);
46 f=fragment fragment
47 syn
48 clamp-mss-to-pmtu
49 ecn-tcp-cwr ecn-tcp-ece
50 physdev-is-in physdev-is-out physdev-is-bridged
51 set rcheck update remove rttl rsource rdest
52 save-mark restore-mark
53 log-tcp-sequence log-tcp-options log-ip-options log-uid
54 continue tee
55 strict next
56 fragres fragfirst fragmore fraglast
57 nodst random
58 ecn-tcp-remove
59 ahres
60 soft
61 rt-0-res rt-0-not-strict
62 ashort
63 save restore
64 new
65 ssrr lsrr no-srr rr ts ra any-opt
66 utc localtz
67 EOT
69 my %p1 = parse_table(<<EOT);
70 p=protocol
71 i=interface o=outerface
72 in-interface=interface out-interface=outerface
73 s=saddr d=daddr
74 source=saddr destination=daddr
75 dport sport
76 reject-with icmp-type icmpv6-type
77 to-ports to
78 tos mark
79 tcp-option mss set-mss
80 ttl-set
81 ulog-nlgroup ulog-prefix ulog-cprange ulog-qthreshold
82 src-type dst-type
83 ahspi ahlen
84 ctstate ctproto ctorigsrc ctorigdst ctreplsrc ctrepldst ctstatus ctexpire
85 dscp dscp-class
86 dstlimit
87 ecn-ip-ect
88 espspi
89 helper
90 iplimit-above iplimit-mask
91 src-range dst-range
92 length
93 limit limit-burst
94 mac-source
95 every counter start packet
96 uid-owner gid-owner pid-owner sid-owner cmd-owner
97 physdev-in physdev-out
98 pkt-type
99 psd-weight-threshold psd-delay-threshold
100 psd-lo-ports-weight psd-hi-ports-weight
101 average
102 realm
103 name seconds hitcount
104 timestart timestop days datestart datestop
106 ttl-eq ttl-gt ttl-lt
107 set-class
108 set-mark set-xmark and-mark or-mark xor-mark mask
109 log-level log-prefix
110 oif iif gw
111 set-tos
112 ttl-set ttl-dec ttl-inc
113 comment
114 dir pol reqid spi proto mode tunnel-src tunnel-dst
115 dst-len
116 fragid fraglen
117 dccp-option
118 queue-num
119 set-dscp set-dscp-class
120 quota
121 condition
122 lower-limit upper-limit
123 hbh-len hbh-opts
124 hl-eq hl-lt hl-gt
125 rt-type rt-segsleft rt-len
126 hl-set hl-dec hl-inc
127 aaddr aname
128 hashlimit hashlimit-burst hashlimit-mode hashlimit-name
129 hashlimit-htable-size hashlimit-htable-max
130 hashlimit-htable-expire hashlimit-htable-gcinterval
131 connlimit-above connlimit-mask
132 connbytes connbytes-dir connbytes-mode
133 selctx
134 mh-type
135 probability
136 nflog-group nflog-prefix nflog-range nflog-threshold
137 hashmode clustermac total-nodes local-node hash-init
138 algo from string hex-string
139 and-tos or-tos xor-tos
142 my %p1c = parse_table(<<EOT);
143 state
144 source-ports destination-ports ports
145 dst-opts
146 dccp-types
147 header
148 rt-0-addrs
149 monthday weekdays
152 my %p1multi = parse_table(<<EOT);
153 to-source
154 to-destination
158 my %p2c = parse_table(<<EOT);
159 tcp-flags
160 chunk-types
161 add-set del-set
164 my @pre_negated = qw(fragment connbytes connlimit-above
165 rr ts ra any-opt
166 set rcheck update remove seconds hitcount
167 syn mss
168 physdev-is-in physdev-is-out physdev-is-bridged
171 my @mix_negated = qw(
172 iplimit-above
173 src-range dst-range
174 realm
178 my @negated = qw(
179 protocol saddr daddr interface outerface sport dport
180 ahspi ahlen condition connrate
181 ctorigsrc ctorigdst ctreplsrc ctrepldst
182 espspi src-cc dst-cc
183 icmp-type icmpv6-type
184 mac-source source-ports destination-ports ports
185 genre realm chunk-types
186 tcp-flags tcp-option
187 dst-len hbh-len
188 fragid fraglen
189 dccp-types dccp-option
190 condition
191 hl-eq
192 header
193 length
194 physdev-in physdev-out
195 rt-type rt-segsleft rt-len
196 mh-type
199 sub is_netfilter_core_target {
200 my $target = shift;
201 die unless defined $target and length $target;
203 return $target =~ /(?:ACCEPT|DROP|RETURN|QUEUE|NOP)$/;
206 sub is_netfilter_module_target {
207 my $target = shift;
208 die unless defined $target and length $target;
210 return $target =~ /(?:BALANCE|CLASSIFY|CLUSTERIP|CONNMARK
211 |DNAT|DSCP|ECN|LOG|MARK|MASQUERADE
212 |MIRROR|NETMAP|REDIRECT|REJECT|ROUTE
213 |SNAT|TCPMSS|TOS|TRACE|TTL|ULOG
214 |TARPIT
215 )/x;
218 sub ferm_escape {
219 local $_ = shift;
220 return $_ unless /[^-\w.:]/s;
221 return "\'$_\'";
224 sub format_array {
225 my $a = shift;
226 return ferm_escape($a) unless ref $a;
227 return ferm_escape($a->[0]) if @$a == 1;
228 return '(' . join(' ', map { ferm_escape($_) } @$a) . ')';
231 sub write_line {
232 # write a line of tokens, with indent handling
234 # don't add space before semicolon
235 my $comma = $_[-1] eq ';' ? pop : '';
236 # begins with closing curly braces -> decrease indent
237 $indent -= 4 if $_[0] =~ /^}/;
238 # do print line
239 print ' ' x $indent;
240 print join(' ', @_);
241 print "$comma\n";
242 # ends with opening curly braces -> increase indent
243 $indent += 4 if $_[-1] =~ /{$/;
246 sub module_match_count {
247 my ($module, $rules) = @_;
248 my $count = 0;
249 foreach (@$rules) {
250 last unless $_->{mod}{$module};
251 $count++;
253 return $count;
256 sub prefix_matches {
257 my ($a, $b) = @_;
258 return @{$b->{match}} > 0 &&
259 (Dumper($a->{match}[0]) eq Dumper($b->{match}[0]));
262 sub prefix_match_count {
263 my ($prefix, $rules) = @_;
264 my $count = 0;
265 foreach (@$rules) {
266 last unless prefix_matches($prefix, $_);
267 $count++;
269 return $count;
272 sub is_merging_array_member {
273 my $value = shift;
274 return defined $value &&
275 ((!ref($value)) or
276 ref $value eq 'ARRAY');
279 sub array_matches($$) {
280 my ($rule1, $rule2) = @_;
281 return unless is_merging_array_member($rule1->{match}[0][1]);
282 return unless is_merging_array_member($rule2->{match}[0][1]);
283 return unless @{$rule2->{match}} > 0;
284 return unless $rule1->{match}[0][0] eq $rule2->{match}[0][0];
285 my %r1 = %$rule1;
286 my %r2 = %$rule2;
287 $r1{match} = [ @{$r1{match}} ];
288 $r2{match} = [ @{$r2{match}} ];
289 shift @{$r1{match}};
290 shift @{$r2{match}};
291 return Dumper(\%r1) eq Dumper(\%r2);
294 sub array_match_count($\@) {
295 my ($first, $rules) = @_;
296 return 0 unless @{$first->{match}} > 0;
297 my $count = 0;
298 foreach (@$rules) {
299 last unless array_matches($first, $_);
300 $count++;
302 return $count;
305 sub optimize {
306 my @result;
308 # try to combine rules with arrays:
309 # saddr 1.2.3.4 proto tcp ACCEPT;
310 # saddr 5.6.7.8 proto tcp ACCEPT;
311 # ->
312 # saddr (1.2.3.4 5.6.7.8) proto tcp ACCEPT;
313 while (@_ > 0) {
314 my $rule = shift;
315 my $match_count = array_match_count($rule, @_);
317 if ($match_count > 0) {
318 my $option = $rule->{match}[0][0];
319 my @matching = ( $rule, splice(@_, 0, $match_count) );
320 my @params = map {
321 (ref $_ and ref $_ eq 'ARRAY') ? @$_ : $_
322 } map {
323 $_->{match}[0][1]
324 } @matching;
326 $rule->{match}[0][1] = \@params;
329 push @result, $rule;
332 @_ = @result;
333 undef @result;
335 # try to find a common prefix and put rules in a block:
336 # saddr 1.2.3.4 proto tcp dport ssh ACCEPT;
337 # saddr 5.6.7.8 proto tcp dport ssh DROP;
338 # ->
339 # proto tcp dport ssh {
340 # saddr 1.2.3.4 ACCEPT;
341 # saddr 5.6.7.8 DROP;
343 while (@_ > 0) {
344 my $rule = shift;
345 if (@{$rule->{match}} > 0) {
346 my $match_count = prefix_match_count($rule, \@_);
348 if ($match_count > 0) {
349 my $match = $rule->{match}[0];
350 my @matching = ( $rule, splice(@_, 0, $match_count) );
351 map { shift @{$_->{match}} } @matching;
353 push @result, { match => [ $match ],
354 block => [ optimize(@matching) ],
356 } else {
357 push @result, $rule;
359 } else {
360 push @result, $rule;
364 # combine simple closures:
365 # proto tcp { dport http { LOG; ACCEPT; } }
366 # ->
367 # proto tcp dport http { LOG; ACCEPT; }
368 foreach my $rule (@result) {
369 next unless exists $rule->{block} && @{$rule->{block}} == 1;
371 # a block with only one item can be merged
372 my $inner = $rule->{block}[0];
373 delete $rule->{block};
375 # merge rule
376 push @{$rule->{match}}, @{$inner->{match}};
377 $rule->{jump} = $inner->{jump}
378 if exists $inner->{jump};
379 $rule->{goto} = $inner->{goto}
380 if exists $inner->{goto};
381 $rule->{block} = $inner->{block}
382 if exists $inner->{block};
383 push @{$rule->{target} ||= []}, @{$inner->{target}}
384 if exists $inner->{target};
387 return @result;
390 sub flush_option {
391 my ($line, $key, $value) = @_;
393 if (ref($value) and ref($value) eq 'HASH' and
394 $value->{wrap} eq 'pre-negation') {
395 push @$line, '!';
396 $value = $value->{value};
399 push @$line, $key;
401 if (ref($value) and ref($value) eq 'HASH' and
402 $value->{wrap} eq 'negation') {
403 push @$line, '!';
404 $value = $value->{value};
407 if (ref($value) and ref($value) eq 'HASH' and
408 $value->{wrap} eq 'multi') {
409 foreach (@{$value->{values}}) {
410 push @$line, format_array($_);
412 } elsif (defined $value) {
413 push @$line, format_array($value);
417 sub flush {
418 # optimize and write a list of rules
420 my @r = @_ ? @_ : @rules;
421 @r = optimize(@r);
422 foreach my $rule (@r) {
423 my @line;
424 # assemble the line, match stuff first, then target parameters
425 if (exists $rule->{match}) {
426 foreach (@{$rule->{match}}) {
427 flush_option(\@line, @$_);
431 if (exists $rule->{jump}) {
432 if (is_netfilter_core_target($rule->{jump}) ||
433 is_netfilter_module_target($rule->{jump})) {
434 push @line, $rule->{jump};
435 } else {
436 flush_option(\@line, 'jump', $rule->{jump});
438 } elsif (exists $rule->{goto}) {
439 flush_option(\@line, 'realgoto', $rule->{goto});
440 } elsif (not exists $rule->{block}) {
441 push @line, 'NOP';
444 if (exists $rule->{target}) {
445 foreach (@{$rule->{target}}) {
446 flush_option(\@line, @$_);
450 if (exists $rule->{block}) {
451 # this rule begins a block created in &optimize
452 write_line(@line, '{');
453 flush(@{$rule->{block}});
454 write_line('}');
455 } else {
456 # just a simple rule
457 write_line(@line, ';');
460 undef @rules;
463 sub flush_domain {
464 flush;
465 write_line '}' if defined $chain;
466 write_line '}' if defined $table;
467 write_line '}' if defined $domain;
469 undef $chain;
470 undef $table;
471 undef $domain;
474 sub tokenize {
475 local $_ = shift;
476 my @result;
477 while (s/^\s*"([^"]+)"//s || s/^\s*(!)// || s/^\s*(\S+)//s) {
478 push @result, $1;
480 return @result;
483 sub wrap_pre_negated {
484 my ($option, $negated_ref, $value) = @_;
485 return $value unless $negated_ref && $$negated_ref;
487 my $wrap;
488 if (grep { $_ eq $option } @pre_negated) {
489 $wrap = 'pre-negation';
490 } elsif (grep { $_ eq $option } @mix_negated) {
491 $wrap = 'negation';
494 die "option '$option' in line $. cannot be pre-negated\n"
495 unless defined $wrap;
497 undef $$negated_ref;
499 return { wrap => $wrap,
500 value => $value,
504 sub fetch_token {
505 my ($option, $tokens) = @_;
506 die "not enough arguments for option '$option' in line $."
507 unless @$tokens > 0;
508 shift @$tokens;
511 sub fetch_token_comma {
512 [ split(',', fetch_token(@_)) ]
515 sub fetch_two_tokens_comma {
516 return { wrap => 'multi',
517 values => [ fetch_token_comma(@_),
518 fetch_token_comma(@_) ],
522 sub wrap_negated {
523 my ($option, $tokens, $fetch) = (shift, shift, shift);
525 my $negated = @$tokens > 0 && $tokens->[0] eq '!' && shift @$tokens;
527 die "option '$option' in line $. cannot be negated\n"
528 if $negated and not grep { $_ eq $option } @negated;
530 my $value = &$fetch($option, $tokens, @_);
532 $value = { wrap => 'negation',
533 value => $value,
535 if $negated;
537 return $value;
540 sub parse_option {
541 my ($line, $option, $pre_negated, $tokens) = @_;
543 my $cur = $line->{cur};
544 die unless defined $cur;
546 $option = 'destination-ports' if $option eq 'dports';
547 $option = 'source-ports' if $option eq 'sports';
549 if ($option eq 'protocol') {
550 my $keyword = 'protocol';
551 my $param = wrap_negated($keyword, $tokens, \&fetch_token);
552 # protocol implicitly loads the module
553 unless (ref $param) {
554 my $mod = $param eq 'ipv6-icmp' ? 'icmp6' : $param;
555 delete $line->{mod}{$mod};
557 $line->{proto} = $param;
558 push @$cur, [ 'proto', $param ];
559 } elsif ($option eq 'm') {
560 die unless @$tokens;
561 my $param = shift @$tokens;
562 $line->{mod}{$param} = 1;
563 # we don't need this module if the protocol with the
564 # same name is already specified
565 push @$cur, [ 'mod', $param ]
566 unless exists $line->{proto} and
567 ($line->{proto} eq $param or
568 $line->{proto} =~ /^(ipv6-icmp|icmpv6)$/s and $param eq 'icmp6');
569 } elsif ($option eq 'set' and exists $line->{mod}{set}) {
570 push @$cur, [ $option, fetch_two_tokens_comma($option, $tokens) ];
571 } elsif (exists $p0{$option}) {
572 my $keyword = $p0{$option};
573 push @$cur, [ $keyword, wrap_pre_negated($keyword, \$pre_negated, undef) ];
574 } elsif (exists $p1{$option}) {
575 my $keyword = $p1{$option};
576 my $param = wrap_negated($keyword, $tokens, \&fetch_token);
577 my $value = wrap_pre_negated($keyword, \$pre_negated, $param);
578 unless ($keyword =~ /^[sd]addr$/ && $value eq '::/0') {
579 push @$cur, [ $keyword, $value ];
581 } elsif (exists $p1c{$option}) {
582 my $keyword = $p1c{$option};
583 my $param = wrap_negated($keyword, $tokens, \&fetch_token_comma);
584 push @$cur, [ $keyword, wrap_pre_negated($keyword, \$pre_negated, $param) ];
585 } elsif (exists $p1multi{$option}) {
586 my $keyword = $p1multi{$option};
587 my $values;
588 my @v = grep { $_->[0] eq $keyword } @$cur;
589 if (@v == 0) {
590 $values = [];
591 push @$cur, [ $keyword, $values ];
592 } else {
593 $values = $v[0][1];
595 my $param = wrap_negated($keyword, $tokens, \&fetch_token);
596 push @$values, wrap_pre_negated($option, \$pre_negated, $param);
597 } elsif (exists $p2c{$option}) {
598 my $keyword = $p2c{$option};
599 my $param = wrap_negated($keyword, $tokens, \&fetch_two_tokens_comma);
600 push @$cur, [ $keyword, wrap_pre_negated($keyword, \$pre_negated, $param) ];
601 } elsif ($option eq 'j') {
602 die unless @$tokens;
603 my $target = shift @$tokens;
604 # store the target in $line->{jump}
605 $line->{jump} = $target;
606 # what now follows is target parameters; set $cur
607 # correctly
608 $line->{cur} = $line->{target} = [];
609 } elsif ($option eq 'g') {
610 die unless @$tokens;
611 my $target = shift @$tokens;
612 # store the target in $line->{jump}
613 $line->{goto} = $target;
614 } else {
615 die "option '$option' in line $. not understood\n";
618 die "option '$option' in line $. cannot be negated\n"
619 if $pre_negated;
622 if (grep { $_ eq '-h' || $_ eq '--help' } @ARGV) {
623 require Pod::Usage;
624 Pod::Usage::pod2usage(-exitstatus => 0,
625 -verbose => 99);
628 if (@ARGV == 0 && -t STDIN) {
629 open STDIN, "/sbin/iptables-save|"
630 or die "Failed run to /sbin/iptables-save: $!";
631 } elsif (grep { /^-./ } @ARGV) {
632 require Pod::Usage;
633 Pod::Usage::pod2usage(-exitstatus => 1,
634 -verbose => 99);
637 print "# ferm rules generated by import-ferm\n";
638 print "# http://ferm.foo-projects.org/\n";
640 $next_domain = $ENV{FERM_DOMAIN} || 'ip';
642 my %policies;
644 while (<>) {
645 if (/^(?:#.*)?$/) {
646 # empty or comment
648 $next_domain = $1 if /^#.*\b(ip|ip6)tables(?:-save)\b/;
649 } elsif (/^\*(\w+)$/) {
650 # table
652 if (keys %policies > 0) {
653 while (my ($chain, $policy) = each %policies) {
654 write_line('chain', $chain, 'policy', $policy, ';');
656 undef %policies;
659 unless (defined $domain and $domain eq $next_domain) {
660 flush_domain;
661 $domain = $next_domain;
662 write_line 'domain', $domain, '{';
665 write_line('}') if defined $table;
666 $table = $1;
667 write_line('table', $table, '{');
668 } elsif (/^:(\S+)\s+-\s+/) {
669 # custom chain
670 die unless defined $table;
671 write_line("chain $1;");
672 } elsif (/^:(\S+)\s+(\w+)\s+/) {
673 # built-in chain
674 die unless defined $table;
675 $policies{$1} = $2;
676 } elsif (s/^-A (\S+)\s+//) {
677 # a rule
678 unless (defined $chain) {
679 flush;
680 $chain = $1;
681 write_line('chain', $chain, '{');
682 } elsif ($1 ne $chain) {
683 flush;
684 write_line('}');
685 $chain = $1;
686 write_line('chain', $chain, '{');
689 if (exists $policies{$chain}) {
690 write_line('policy', $policies{$chain}, ';');
691 delete $policies{$chain};
694 my @tokens = tokenize($_);
696 my %line;
697 # separate 'match' parameters from 'targe't parameters; $cur
698 # points to the current position
699 $line{cur} = $line{match} = [];
700 while (@tokens) {
701 local $_ = shift @tokens;
702 if (/^-(\w)$/ || /^--(\S+)$/) {
703 parse_option(\%line, $1, undef, \@tokens);
704 } elsif ($_ eq '!') {
705 die unless @tokens;
706 $_ = shift @tokens;
707 /^-(\w)$/ || /^--(\S+)$/
708 or die "option expected in line $.\n";
709 parse_option(\%line, $1, 1, \@tokens);
710 } else {
711 print STDERR "warning: unknown token '$_' in line $.\n";
714 delete $line{cur};
715 push @rules, \%line;
716 } elsif ($_ =~ /^COMMIT/) {
717 flush;
719 if (defined $chain) {
720 write_line('}');
721 undef $chain;
723 } else {
724 print STDERR "line $. was not understood, ignoring it\n";
728 if (keys %policies > 0) {
729 while (my ($chain, $policy) = each %policies) {
730 write_line('chain', $chain, 'policy', $policy, ';');
734 flush_domain if defined $domain;
736 die unless $indent == 0;
738 __END__
740 =head1 NAME
742 import-ferm - import existing firewall rules into ferm
744 =head1 SYNOPSIS
746 B<import-ferm> > ferm.conf
748 iptables-save | B<import-ferm> > ferm.conf
750 B<import-ferm> I<inputfile> > ferm.conf
752 =head1 DESCRIPTION
754 This script helps you with porting an existing IPv4 firewall
755 configuration to ferm. It reads a file generated with
756 B<iptables-save>, and tries to suggest a ferm configuration file.
758 If no input file was specified on the command line, B<import-ferm>
759 runs F<iptables-save>.
761 =head1 BUGS
763 iptables-save older than 1.3 is unable to write valid saves - this is
764 not a bug in B<import-ferm>.
766 =cut