import-ferm: write policy in chain block
[ferm.git] / src / import-ferm
blob51bca572ef83aef7802119ac2f2a118508802283
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
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
58 ecn-tcp-remove
59 ahres
60 soft
61 rt-0-res rt-0-not-strict
62 ashort
63 save restore
64 EOT
66 my %p1 = parse_table(<<EOT);
67 p=protocol
68 i=interface o=outerface
69 in-interface=interface out-interface=outerface
70 s=saddr d=daddr
71 source=saddr destination=daddr
72 dport sport
73 reject-with icmp-type icmpv6-type
74 to-destination to-ports to
75 tos mark
76 tcp-option mss set-mss
77 ttl-set
78 ulog-nlgroup ulog-prefix ulog-cprange ulog-qthreshold
79 src-type dst-type
80 ahspi ahlen
81 ctstate ctproto ctorigsrc ctorigdst ctreplsrc ctrepldst ctstatus ctexpire
82 dscp dscp-class
83 dstlimit
84 ecn-ip-ect
85 espspi
86 helper
87 iplimit-above iplimit-mask
88 src-range dst-range
89 length
90 limit limit-burst
91 mac-source
92 every counter start packet
93 uid-owner gid-owner pid-owner sid-owner cmd-owner
94 physdev-in physdev-out
95 pkt-type
96 psd-weight-threshold psd-delay-threshold
97 psd-lo-ports-weight psd-hi-ports-weight
98 average
99 realm
100 name seconds hitcount
101 timestart timestop days datestart datestop
103 ttl-eq ttl-gt ttl-lt
104 set-class
105 set-mark mask
106 log-level log-prefix
107 oif iif gw
108 set-tos
109 ttl-set ttl-dec ttl-inc
110 comment
111 dir pol reqid spi proto mode tunnel-src tunnel-dst
112 dst-len
113 fragid fraglen
114 dccp-option
115 queue-num
116 set-dscp set-dscp-class
117 quota
118 condition
119 lower-limit upper-limit
120 hbh-len hbh-opts
121 hl-eq hl-lt hl-gt
122 rt-type rt-segsleft rt-len
123 hl-set hl-dec hl-inc
124 aaddr aname
125 hashlimit hashlimit-burst hashlimit-mode hashlimit-name
126 hashlimit-htable-size hashlimit-htable-max
127 hashlimit-htable-expire hashlimit-htable-gcinterval
128 connlimit-above connlimit-mask
129 connbytes connbytes-dir connbytes-mode
130 selctx
131 mh-type
132 probability
133 nflog-group nflog-prefix nflog-range nflog-threshold
136 my %p1c = parse_table(<<EOT);
137 state
138 source-ports destination-ports ports
139 dst-opts
140 dccp-types
141 header
142 rt-0-addrs
145 my %p1multi = parse_table(<<EOT);
146 to-source
149 my %p2c = parse_table(<<EOT);
150 tcp-flags
151 chunk-types
152 add-set del-set
155 my @pre_negated = qw(fragment connbytes connlimit-above
156 rr ts ra any-opt
157 set rcheck update remove seconds hitcount
158 syn mss
159 physdev-is-in physdev-is-out physdev-is-bridged
162 my @mix_negated = qw(
163 iplimit-above
164 src-range dst-range
165 realm
169 my @negated = qw(
170 protocol saddr daddr interface outerface sport dport
171 ahspi ahlen condition connrate
172 ctorigsrc ctorigdst ctreplsrc ctrepldst
173 espspi src-cc dst-cc
174 icmp-type icmpv6-type
175 mac-source source-ports destination-ports ports
176 genre realm chunk-types
177 tcp-flags tcp-option
178 dst-len hbh-len
179 fragid fraglen
180 dccp-types dccp-option
181 condition
182 hl-eq
183 header
184 length
185 physdev-in physdev-out
186 rt-type rt-segsleft rt-len
187 mh-type
190 sub is_netfilter_core_target {
191 my $target = shift;
192 die unless defined $target and length $target;
194 return $target =~ /(?:ACCEPT|DROP|RETURN|QUEUE|NOP)$/;
197 sub is_netfilter_module_target {
198 my $target = shift;
199 die unless defined $target and length $target;
201 return $target =~ /(?:BALANCE|CLASSIFY|CLUSTERIP|CONNMARK
202 |DNAT|DSCP|ECN|LOG|MARK|MASQUERADE
203 |MIRROR|NETMAP|REDIRECT|REJECT|ROUTE
204 |SNAT|TCPMSS|TOS|TRACE|TTL|ULOG
205 |TARPIT
206 )/x;
209 sub ferm_escape {
210 local $_ = shift;
211 return $_ unless /[^-\w.:]/s;
212 return "\'$_\'";
215 sub format_array {
216 my $a = shift;
217 return ferm_escape($a) unless ref $a;
218 return ferm_escape($a->[0]) if @$a == 1;
219 return '(' . join(' ', @$a) . ')';
222 sub write_line {
223 # write a line of tokens, with indent handling
225 # don't add space before semicolon
226 my $comma = $_[-1] eq ';' ? pop : '';
227 # begins with closing curly braces -> decrease indent
228 $indent -= 4 if $_[0] =~ /^}/;
229 # do print line
230 print ' ' x $indent;
231 print join(' ', @_);
232 print "$comma\n";
233 # ends with opening curly braces -> increase indent
234 $indent += 4 if $_[-1] =~ /{$/;
237 sub module_match_count {
238 my ($module, $rules) = @_;
239 my $count = 0;
240 foreach (@$rules) {
241 last unless $_->{mod}{$module};
242 $count++;
244 return $count;
247 sub prefix_matches {
248 my ($a, $b) = @_;
249 return @{$b->{match}} > 0 &&
250 (Dumper($a->{match}[0]) eq Dumper($b->{match}[0]));
253 sub prefix_match_count {
254 my ($prefix, $rules) = @_;
255 my $count = 0;
256 foreach (@$rules) {
257 last unless prefix_matches($prefix, $_);
258 $count++;
260 return $count;
263 sub is_merging_array_member {
264 my $value = shift;
265 return defined $value &&
266 ((!ref($value)) or
267 ref $value eq 'ARRAY');
270 sub array_matches($$) {
271 my ($rule1, $rule2) = @_;
272 return unless is_merging_array_member($rule1->{match}[0][1]);
273 return unless is_merging_array_member($rule2->{match}[0][1]);
274 return unless @{$rule2->{match}} > 0;
275 return unless $rule1->{match}[0][0] eq $rule2->{match}[0][0];
276 my %r1 = %$rule1;
277 my %r2 = %$rule2;
278 $r1{match} = [ @{$r1{match}} ];
279 $r2{match} = [ @{$r2{match}} ];
280 shift @{$r1{match}};
281 shift @{$r2{match}};
282 return Dumper(\%r1) eq Dumper(\%r2);
285 sub array_match_count($\@) {
286 my ($first, $rules) = @_;
287 return 0 unless @{$first->{match}} > 0;
288 my $count = 0;
289 foreach (@$rules) {
290 last unless array_matches($first, $_);
291 $count++;
293 return $count;
296 sub optimize {
297 my @result;
299 # try to combine rules with arrays:
300 # saddr 1.2.3.4 proto tcp ACCEPT;
301 # saddr 5.6.7.8 proto tcp ACCEPT;
302 # ->
303 # saddr (1.2.3.4 5.6.7.8) proto tcp ACCEPT;
304 while (@_ > 0) {
305 my $rule = shift;
306 my $match_count = array_match_count($rule, @_);
308 if ($match_count > 0) {
309 my $option = $rule->{match}[0][0];
310 my @matching = ( $rule, splice(@_, 0, $match_count) );
311 my @params = map {
312 (ref $_ and ref $_ eq 'ARRAY') ? @$_ : $_
313 } map {
314 $_->{match}[0][1]
315 } @matching;
317 $rule->{match}[0][1] = \@params;
320 push @result, $rule;
323 @_ = @result;
324 undef @result;
326 # try to find a common prefix and put rules in a block:
327 # saddr 1.2.3.4 proto tcp dport ssh ACCEPT;
328 # saddr 5.6.7.8 proto tcp dport ssh DROP;
329 # ->
330 # proto tcp dport ssh {
331 # saddr 1.2.3.4 ACCEPT;
332 # saddr 5.6.7.8 DROP;
334 while (@_ > 0) {
335 my $rule = shift;
336 if (@{$rule->{match}} > 0) {
337 my $match_count = prefix_match_count($rule, \@_);
339 if ($match_count > 0) {
340 my $match = $rule->{match}[0];
341 my @matching = ( $rule, splice(@_, 0, $match_count) );
342 map { shift @{$_->{match}} } @matching;
344 push @result, { match => [ $match ],
345 block => [ optimize(@matching) ],
347 } else {
348 push @result, $rule;
350 } else {
351 push @result, $rule;
355 # combine simple closures:
356 # proto tcp { dport http { LOG; ACCEPT; } }
357 # ->
358 # proto tcp dport http { LOG; ACCEPT; }
359 foreach my $rule (@result) {
360 next unless exists $rule->{block} && @{$rule->{block}} == 1;
362 # a block with only one item can be merged
363 my $inner = $rule->{block}[0];
364 delete $rule->{block};
366 # merge rule
367 push @{$rule->{match}}, @{$inner->{match}};
368 $rule->{jump} = $inner->{jump}
369 if exists $inner->{jump};
370 $rule->{goto} = $inner->{goto}
371 if exists $inner->{goto};
372 $rule->{block} = $inner->{block}
373 if exists $inner->{block};
374 push @{$rule->{target} ||= []}, @{$inner->{target}}
375 if exists $inner->{target};
378 return @result;
381 sub flush_option {
382 my ($line, $key, $value) = @_;
384 if (ref($value) and ref($value) eq 'HASH' and
385 $value->{wrap} eq 'pre-negation') {
386 push @$line, '!';
387 $value = $value->{value};
390 push @$line, $key;
392 if (ref($value) and ref($value) eq 'HASH' and
393 $value->{wrap} eq 'negation') {
394 push @$line, '!';
395 $value = $value->{value};
398 if (ref($value) and ref($value) eq 'HASH' and
399 $value->{wrap} eq 'multi') {
400 foreach (@{$value->{values}}) {
401 push @$line, format_array($_);
403 } elsif (defined $value) {
404 push @$line, format_array($value);
408 sub flush {
409 # optimize and write a list of rules
411 my @r = @_ ? @_ : @rules;
412 @r = optimize(@r);
413 foreach my $rule (@r) {
414 my @line;
415 # assemble the line, match stuff first, then target parameters
416 if (exists $rule->{match}) {
417 foreach (@{$rule->{match}}) {
418 flush_option(\@line, @$_);
422 if (exists $rule->{jump}) {
423 if (is_netfilter_core_target($rule->{jump}) ||
424 is_netfilter_module_target($rule->{jump})) {
425 push @line, $rule->{jump};
426 } else {
427 flush_option(\@line, 'jump', $rule->{jump});
429 } elsif (exists $rule->{goto}) {
430 flush_option(\@line, 'realgoto', $rule->{goto});
431 } elsif (not exists $rule->{block}) {
432 push @line, 'NOP';
435 if (exists $rule->{target}) {
436 foreach (@{$rule->{target}}) {
437 flush_option(\@line, @$_);
441 if (exists $rule->{block}) {
442 # this rule begins a block created in &optimize
443 write_line(@line, '{');
444 flush(@{$rule->{block}});
445 write_line('}');
446 } else {
447 # just a simple rule
448 write_line(@line, ';');
451 undef @rules;
454 sub flush_domain {
455 flush;
456 write_line '}' if defined $chain;
457 write_line '}' if defined $table;
458 write_line '}' if defined $domain;
460 undef $chain;
461 undef $table;
462 undef $domain;
465 sub tokenize {
466 local $_ = shift;
467 my @result;
468 while (s/^\s*"([^"]+)"//s || s/^\s*(!)// || s/^\s*(\S+)//s) {
469 push @result, $1;
471 return @result;
474 sub wrap_pre_negated {
475 my ($option, $negated_ref, $value) = @_;
476 return $value unless $negated_ref && $$negated_ref;
478 my $wrap;
479 if (grep { $_ eq $option } @pre_negated) {
480 $wrap = 'pre-negation';
481 } elsif (grep { $_ eq $option } @mix_negated) {
482 $wrap = 'negation';
485 die "option '$option' in line $. cannot be pre-negated\n"
486 unless defined $wrap;
488 undef $$negated_ref;
490 return { wrap => $wrap,
491 value => $value,
495 sub fetch_token {
496 my ($option, $tokens) = @_;
497 die "not enough arguments for option '$option' in line $."
498 unless @$tokens > 0;
499 shift @$tokens;
502 sub fetch_token_comma {
503 [ split(',', fetch_token(@_)) ]
506 sub fetch_two_tokens_comma {
507 return { wrap => 'multi',
508 values => [ fetch_token_comma(@_),
509 fetch_token_comma(@_) ],
513 sub wrap_negated {
514 my ($option, $tokens, $fetch) = (shift, shift, shift);
516 my $negated = @$tokens > 0 && $tokens->[0] eq '!' && shift @$tokens;
518 die "option '$option' in line $. cannot be negated\n"
519 if $negated and not grep { $_ eq $option } @negated;
521 my $value = &$fetch($option, $tokens, @_);
523 $value = { wrap => 'negation',
524 value => $value,
526 if $negated;
528 return $value;
531 sub parse_option {
532 my ($line, $option, $pre_negated, $tokens) = @_;
534 my $cur = $line->{cur};
535 die unless defined $cur;
537 $option = 'destination-ports' if $option eq 'dports';
538 $option = 'source-ports' if $option eq 'sports';
540 if ($option eq 'protocol') {
541 my $keyword = 'protocol';
542 my $param = wrap_negated($keyword, $tokens, \&fetch_token);
543 # protocol implicitly loads the module
544 unless (ref $param) {
545 my $mod = $param eq 'ipv6-icmp' ? 'icmp6' : $param;
546 delete $line->{mod}{$mod};
548 $line->{proto} = $param;
549 push @$cur, [ 'proto', $param ];
550 } elsif ($option eq 'm') {
551 die unless @$tokens;
552 my $param = shift @$tokens;
553 $line->{mod}{$param} = 1;
554 # we don't need this module if the protocol with the
555 # same name is already specified
556 push @$cur, [ 'mod', $param ]
557 unless exists $line->{proto} and
558 ($line->{proto} eq $param or
559 $line->{proto} =~ /^(ipv6-icmp|icmpv6)$/s and $param eq 'icmp6');
560 } elsif ($option eq 'set' and exists $line->{mod}{set}) {
561 push @$cur, [ $option, fetch_two_tokens_comma($option, $tokens) ];
562 } elsif (exists $p0{$option}) {
563 my $keyword = $p0{$option};
564 push @$cur, [ $keyword, wrap_pre_negated($keyword, \$pre_negated, undef) ];
565 } elsif (exists $p1{$option}) {
566 my $keyword = $p1{$option};
567 my $param = wrap_negated($keyword, $tokens, \&fetch_token);
568 my $value = wrap_pre_negated($keyword, \$pre_negated, $param);
569 unless ($keyword =~ /^[sd]addr$/ && $value eq '::/0') {
570 push @$cur, [ $keyword, $value ];
572 } elsif (exists $p1c{$option}) {
573 my $keyword = $p1c{$option};
574 my $param = wrap_negated($keyword, $tokens, \&fetch_token_comma);
575 push @$cur, [ $keyword, wrap_pre_negated($keyword, \$pre_negated, $param) ];
576 } elsif (exists $p1multi{$option}) {
577 my $keyword = $p1multi{$option};
578 my $values;
579 my @v = grep { $_->[0] eq $keyword } @$cur;
580 if (@v == 0) {
581 $values = [];
582 push @$cur, [ $keyword, $values ];
583 } else {
584 $values = $v[0][1];
586 my $param = wrap_negated($keyword, $tokens, \&fetch_token);
587 push @$values, wrap_pre_negated($option, \$pre_negated, $param);
588 } elsif (exists $p2c{$option}) {
589 my $keyword = $p2c{$option};
590 my $param = wrap_negated($keyword, $tokens, \&fetch_two_tokens_comma);
591 push @$cur, [ $keyword, wrap_pre_negated($keyword, \$pre_negated, $param) ];
592 } elsif ($option eq 'j') {
593 die unless @$tokens;
594 my $target = shift @$tokens;
595 # store the target in $line->{jump}
596 $line->{jump} = $target;
597 # what now follows is target parameters; set $cur
598 # correctly
599 $line->{cur} = $line->{target} = [];
600 } elsif ($option eq 'g') {
601 die unless @$tokens;
602 my $target = shift @$tokens;
603 # store the target in $line->{jump}
604 $line->{goto} = $target;
605 } else {
606 die "option '$option' in line $. not understood\n";
609 die "option '$option' in line $. cannot be negated\n"
610 if $pre_negated;
613 if (grep { $_ eq '-h' || $_ eq '--help' } @ARGV) {
614 require Pod::Usage;
615 Pod::Usage::pod2usage(-exitstatus => 0,
616 -verbose => 99);
619 if (@ARGV == 0 && -t STDIN) {
620 open STDIN, "/sbin/iptables-save|"
621 or die "Failed run to /sbin/iptables-save: $!";
622 } elsif (grep { /^-./ } @ARGV) {
623 require Pod::Usage;
624 Pod::Usage::pod2usage(-exitstatus => 1,
625 -verbose => 99);
628 print "# ferm rules generated by import-ferm\n";
629 print "# http://ferm.foo-projects.org/\n";
631 $next_domain = $ENV{FERM_DOMAIN} || 'ip';
633 my %policies;
635 while (<>) {
636 if (/^(?:#.*)?$/) {
637 # empty or comment
639 $next_domain = $1 if /^#.*\b(ip|ip6)tables(?:-save)\b/;
640 } elsif (/^\*(\w+)$/) {
641 # table
643 if (keys %policies > 0) {
644 while (my ($chain, $policy) = each %policies) {
645 write_line('chain', $chain, 'policy', $policy, ';');
647 undef %policies;
650 unless (defined $domain and $domain eq $next_domain) {
651 flush_domain;
652 $domain = $next_domain;
653 write_line 'domain', $domain, '{';
656 write_line('}') if defined $table;
657 $table = $1;
658 write_line('table', $table, '{');
659 } elsif (/^:(\S+)\s+-\s+/) {
660 # custom chain
661 die unless defined $table;
662 write_line("chain $1;");
663 } elsif (/^:(\S+)\s+(\w+)\s+/) {
664 # built-in chain
665 die unless defined $table;
666 $policies{$1} = $2;
667 } elsif (s/^-A (\S+)\s+//) {
668 # a rule
669 unless (defined $chain) {
670 flush;
671 $chain = $1;
672 write_line('chain', $chain, '{');
673 } elsif ($1 ne $chain) {
674 flush;
675 write_line('}');
676 $chain = $1;
677 write_line('chain', $chain, '{');
680 if (exists $policies{$chain}) {
681 write_line('policy', $policies{$chain}, ';');
682 delete $policies{$chain};
685 my @tokens = tokenize($_);
687 my %line;
688 # separate 'match' parameters from 'targe't parameters; $cur
689 # points to the current position
690 $line{cur} = $line{match} = [];
691 while (@tokens) {
692 local $_ = shift @tokens;
693 if (/^-(\w)$/ || /^--(\S+)$/) {
694 parse_option(\%line, $1, undef, \@tokens);
695 } elsif ($_ eq '!') {
696 die unless @tokens;
697 $_ = shift @tokens;
698 /^-(\w)$/ || /^--(\S+)$/
699 or die "option expected in line $.\n";
700 parse_option(\%line, $1, 1, \@tokens);
701 } else {
702 print STDERR "warning: unknown token '$_' in line $.\n";
705 delete $line{cur};
706 push @rules, \%line;
707 } elsif ($_ =~ /^COMMIT/) {
708 flush;
710 if (defined $chain) {
711 write_line('}');
712 undef $chain;
714 } else {
715 print STDERR "line $. was not understood, ignoring it\n";
719 if (keys %policies > 0) {
720 while (my ($chain, $policy) = each %policies) {
721 write_line('chain', $chain, 'policy', $policy, ';');
725 flush_domain if defined $domain;
727 die unless $indent == 0;
729 __END__
731 =head1 NAME
733 import-ferm - import existing firewall rules into ferm
735 =head1 SYNOPSIS
737 B<import-ferm> > ferm.conf
739 iptables-save | B<import-ferm> > ferm.conf
741 B<import-ferm> I<inputfile> > ferm.conf
743 =head1 DESCRIPTION
745 This script helps you with porting an existing IPv4 firewall
746 configuration to ferm. It reads a file generated with
747 B<iptables-save>, and tries to suggest a ferm configuration file.
749 If no input file was specified on the command line, B<import-ferm>
750 runs F<iptables-save>.
752 =head1 BUGS
754 iptables-save older than 1.3 is unable to write valid saves - this is
755 not a bug in B<import-ferm>.
757 =cut