1#
2# (c) Jan Gehring <jan.gehring@gmail.com>
3#
4# vim: set ts=2 sw=2 tw=0:
5# vim: set expandtab:
6
7=head1 NAME
8
9Rex::Commands::Iptables - Iptable Management Commands
10
11=head1 DESCRIPTION
12
13With this Module you can manage basic Iptables rules.
14
15Version <= 1.0: All these functions will not be reported.
16
17Only I<open_port> and I<close_port> are idempotent.
18
19=head1 SYNOPSIS
20
21 use Rex::Commands::Iptables;
22
23 task "firewall", sub {
24   iptables_clear;
25
26   open_port 22;
27   open_port [22, 80] => {
28     dev => "eth0",
29   };
30
31   close_port 22 => {
32     dev => "eth0",
33   };
34   close_port "all";
35
36   redirect_port 80 => 10080;
37   redirect_port 80 => {
38     dev => "eth0",
39     to  => 10080,
40   };
41
42   default_state_rule;
43   default_state_rule dev => "eth0";
44
45   is_nat_gateway;
46
47   iptables t => "nat",
48         A => "POSTROUTING",
49         o => "eth0",
50         j => "MASQUERADE";
51
52   # The 'iptables' function also accepts long options,
53   # however, options with dashes need to be quoted
54   iptables table => "nat",
55         accept          => "POSTROUTING",
56         "out-interface" => "eth0",
57         jump            => "MASQUERADE";
58
59   # Version of IP can be specified in the first argument
60   # of any function: -4 or -6 (defaults to -4)
61   iptables_clear -6;
62
63   open_port -6, [22, 80];
64   close_port -6, "all";
65   redirect_port -6, 80 => 10080;
66   default_state_rule -6;
67
68   iptables -6, "flush";
69   iptables -6,
70         t     => "filter",
71         A     => "INPUT",
72         i     => "eth0",
73         m     => "state",
74         state => "RELATED,ESTABLISHED",
75         j     => "ACCEPT";
76 };
77
78=head1 EXPORTED FUNCTIONS
79
80=cut
81
82package Rex::Commands::Iptables;
83
84use 5.010001;
85use strict;
86use warnings;
87use version;
88
89our $VERSION = '1.13.4'; # VERSION
90
91require Rex::Exporter;
92use Data::Dumper;
93
94use base qw(Rex::Exporter);
95
96use vars qw(@EXPORT);
97
98use Rex::Commands::Sysctl;
99use Rex::Commands::Gather;
100use Rex::Commands::Fs;
101use Rex::Commands::Run;
102use Rex::Helper::Run;
103
104use Rex::Logger;
105
106@EXPORT = qw(iptables is_nat_gateway iptables_list iptables_clear
107  open_port close_port redirect_port
108  default_state_rule);
109
110sub iptables;
111
112=head2 open_port($port, $option)
113
114Open a port for inbound connections.
115
116 task "firewall", sub {
117   open_port 22;
118   open_port [22, 80];
119   open_port [22, 80],
120     dev => "eth1";
121 };
122
123 task "firewall", sub {
124  open_port 22,
125    dev    => "eth1",
126    only_if => "test -f /etc/firewall.managed";
127} ;
128
129
130=cut
131
132sub open_port {
133  my @params     = @_;
134  my $ip_version = _get_ip_version( \@params );
135  my ( $port, $option ) = @params;
136
137  my %option_h;
138  if ( ref $option ne "HASH" ) {
139    ( $port, %option_h ) = @params;
140
141    if ( exists $option_h{only_if} ) {
142      i_run( $option_h{only_if}, fail_ok => 1 );
143      if ( $? != 0 ) {
144        return;
145      }
146    }
147
148    delete $option_h{only_if};
149    $option = {%option_h};
150  }
151  _open_or_close_port( $ip_version, "i", "I", "INPUT", "ACCEPT", $port,
152    $option );
153
154}
155
156=head2 close_port($port, $option)
157
158Close a port for inbound connections.
159
160 task "firewall", sub {
161   close_port 22;
162   close_port [22, 80];
163   close_port [22, 80],
164     dev    => "eth0",
165     only_if => "test -f /etc/firewall.managed";
166 };
167
168=cut
169
170sub close_port {
171  my @params     = @_;
172  my $ip_version = _get_ip_version( \@params );
173  my ( $port, $option ) = @params;
174
175  my %option_h;
176  if ( ref $option ne "HASH" ) {
177    ( $port, %option_h ) = @params;
178
179    if ( exists $option_h{only_if} ) {
180      i_run( $option_h{only_if}, fail_ok => 1 );
181      if ( $? != 0 ) {
182        return;
183      }
184    }
185
186    delete $option_h{only_if};
187    $option = {%option_h};
188  }
189
190  _open_or_close_port( $ip_version, "i", "A", "INPUT", "DROP", $port, $option );
191
192}
193
194=head2 redirect_port($in_port, $option)
195
196Redirect $in_port to another local port.
197
198 task "redirects", sub {
199   redirect_port 80 => 10080;
200   redirect_port 80 => {
201     to  => 10080,
202     dev => "eth0",
203   };
204 };
205
206=cut
207
208sub redirect_port {
209  my @params     = @_;
210  my $ip_version = _get_ip_version( \@params );
211  if ( $ip_version == -6 ) {
212    my $iptables_version = _iptables_version($ip_version);
213    if ( $iptables_version < v1.4.18 ) {
214      Rex::Logger::info("iptables < v1.4.18 doesn't support NAT for IPv6");
215      die("iptables < v1.4.18 doesn't support NAT for IPv6");
216    }
217  }
218
219  my ( $in_port, $option ) = @params;
220  my @opts;
221
222  push( @opts, "t", "nat" );
223
224  if ( !ref($option) ) {
225    my $net_info = network_interfaces();
226    my @devs     = keys %{$net_info};
227
228    for my $dev (@devs) {
229      redirect_port(
230        $in_port,
231        {
232          dev => $dev,
233          to  => $option,
234        }
235      );
236    }
237
238    return;
239  }
240
241  unless ( exists $option->{"dev"} ) {
242    my $net_info = network_interfaces();
243    my @devs     = keys %{$net_info};
244
245    for my $dev (@devs) {
246      $option->{"dev"} = $dev;
247      redirect_port( $in_port, $option );
248    }
249
250    return;
251  }
252
253  if ( $option->{"to"} =~ m/^\d+$/ ) {
254    $option->{"proto"} ||= "tcp";
255
256    push( @opts,
257      "I", "PREROUTING",       "i", $option->{"dev"},
258      "p", $option->{"proto"}, "m", $option->{"proto"} );
259    push( @opts,
260      "dport", $in_port, "j", "REDIRECT", "to-ports", $option->{"to"} );
261
262  }
263  else {
264    Rex::Logger::info(
265      "Redirect to other hosts isn't supported right now. Please do it by hand."
266    );
267  }
268
269  iptables $ip_version, @opts;
270}
271
272=head2 iptables(@params)
273
274Write standard iptable comands.
275
276Note that there is a short form for the iptables C<--flush> option; when you
277pass the option of C<-F|"flush"> as the only argument, the command
278C<iptables -F> is run on the connected host.  With the two argument form of
279C<flush> shown in the examples below, the second argument is table you want to
280flush.
281
282 task "firewall", sub {
283   iptables t => "nat", A => "POSTROUTING", o => "eth0", j => "MASQUERADE";
284   iptables t => "filter", i => "eth0", m => "state", state => "RELATED,ESTABLISHED", j => "ACCEPT";
285
286   # automatically flushes all tables; equivalent to 'iptables -F'
287   iptables "flush";
288   iptables -F;
289
290   # flush only the "filter" table
291   iptables flush => "filter";
292   iptables -F => "filter";
293 };
294
295 # Note: options with dashes "-" need to be quoted to escape them from Perl
296 task "long_form_firewall", sub {
297   iptables table => "nat",
298        append          => "POSTROUTING",
299        "out-interface" => "eth0",
300        jump            => "MASQUERADE";
301   iptables table => "filter",
302        "in-interface" => "eth0",
303        match          => "state",
304        state          => "RELATED,ESTABLISHED",
305        jump           => "ACCEPT";
306 };
307
308=cut
309
310sub iptables {
311  my @params   = @_;
312  my $iptables = _get_executable( \@params );
313
314  if ( $params[0] eq "flush" || $params[0] eq "-flush" || $params[0] eq "-F" ) {
315    if ( $params[1] ) {
316      i_run "$iptables -F -t $params[1]";
317    }
318    else {
319      i_run "$iptables -F";
320    }
321
322    return;
323  }
324
325  my $cmd = "";
326  my $n   = -1;
327  while ( $params[ ++$n ] ) {
328    my ( $key, $val ) = reverse @params[ $n, $n++ ];
329
330    if ( ref($key) eq "ARRAY" ) {
331      $cmd .= join( " ", @{$key} );
332      last;
333    }
334
335    if ( length($key) == 1 ) {
336      $cmd .= "-$key $val ";
337    }
338    else {
339      $cmd .= "--$key '$val' ";
340    }
341  }
342
343  my $output = i_run "$iptables $cmd", fail_ok => 1;
344
345  if ( $? != 0 ) {
346    Rex::Logger::info( "Error setting iptable rule: $cmd", "warn" );
347    die("Error setting iptable rule: $cmd; command output: $output");
348  }
349}
350
351=head2 is_nat_gateway
352
353This function creates a NAT gateway for the device the default route points to.
354
355 task "make-gateway", sub {
356   is_nat_gateway;
357   is_nat_gateway -6;
358 };
359
360=cut
361
362sub is_nat_gateway {
363  my @params     = @_;
364  my $ip_version = _get_ip_version( \@params );
365
366  Rex::Logger::debug("Changing this system to a nat gateway.");
367
368  if ( my $ip = can_run("ip") ) {
369
370    my @iptables_option = ();
371
372    my ($default_line) = i_run "$ip $ip_version r |grep ^default";
373    my ($dev)          = ( $default_line =~ m/dev ([a-z0-9]+)/i );
374    Rex::Logger::debug("Default GW Device is $dev");
375
376    if ( $ip_version == -6 ) {
377      die "NAT for IPv6 supported by iptables >= v1.4.18"
378        if _iptables_version($ip_version) < v1.4.18;
379      sysctl "net.ipv6.conf.all.forwarding",     1;
380      sysctl "net.ipv6.conf.default.forwarding", 1;
381      iptables $ip_version,
382        t => "nat",
383        A => "POSTROUTING",
384        o => $dev,
385        j => "MASQUERADE";
386    }
387    else {
388      sysctl "net.ipv4.ip_forward" => 1;
389      iptables t => "nat", A => "POSTROUTING", o => $dev, j => "MASQUERADE";
390    }
391  }
392  else {
393
394    Rex::Logger::info("No ip command found.");
395
396  }
397
398}
399
400=head2 default_state_rule(%option)
401
402Set the default state rules for the given device.
403
404 task "firewall", sub {
405   default_state_rule(dev => "eth0");
406 };
407
408=cut
409
410sub default_state_rule {
411  my @params     = @_;
412  my $ip_version = _get_ip_version( \@params );
413  my (%option)   = @params;
414
415  unless ( exists $option{"dev"} ) {
416    my $net_info = network_interfaces();
417    my @devs     = keys %{$net_info};
418
419    for my $dev (@devs) {
420      default_state_rule( dev => $dev );
421    }
422
423    return;
424  }
425
426  iptables $ip_version,
427    t     => "filter",
428    A     => "INPUT",
429    i     => $option{"dev"},
430    m     => "state",
431    state => "RELATED,ESTABLISHED",
432    j     => "ACCEPT";
433}
434
435=head2 iptables_list
436
437List all iptables rules.
438
439 task "list-iptables", sub {
440   print Dumper iptables_list;
441   print Dumper iptables_list -6;
442 };
443
444=cut
445
446sub iptables_list {
447  my @params   = @_;
448  my $iptables = _get_executable( \@params );
449  my @lines    = i_run "$iptables-save", valid_retval => [ 0, 1 ];
450  _iptables_list(@lines);
451}
452
453sub _iptables_list {
454  my ( %tables, $ret );
455  my @lines = @_;
456
457  my ($current_table);
458  for my $line (@lines) {
459    chomp $line;
460
461    next if ( $line eq "COMMIT" );
462    next if ( $line =~ m/^#/ );
463    next if ( $line =~ m/^:/ );
464
465    if ( $line =~ m/^\*([a-z]+)$/ ) {
466      $current_table = $1;
467      $tables{$current_table} = [];
468      next;
469    }
470
471#my @parts = grep { ! /^\s+$/ && ! /^$/ } split (/(\-\-?[^\s]+\s[^\s]+)/i, $line);
472    my @parts = grep { !/^\s+$/ && !/^$/ } split( /^\-\-?|\s+\-\-?/i, $line );
473
474    my @option = ();
475    for my $part (@parts) {
476      my ( $key, $value ) = split( /\s/, $part, 2 );
477
478      #$key =~ s/^\-+//;
479      push( @option, $key => $value );
480    }
481
482    push( @{ $ret->{$current_table} }, \@option );
483
484  }
485
486  return $ret;
487}
488
489=head2 iptables_clear
490
491Remove all iptables rules.
492
493 task "no-firewall", sub {
494   iptables_clear;
495 };
496
497=cut
498
499sub iptables_clear {
500  my @params     = @_;
501  my $ip_version = _get_ip_version( \@params );
502  my %tables_of  = (
503    -4 => "/proc/net/ip_tables_names",
504    -6 => "/proc/net/ip6_tables_names",
505  );
506
507  if ( is_file("$tables_of{$ip_version}") ) {
508    my @tables = i_run( "cat $tables_of{$ip_version}", fail_ok => 1 );
509    for my $table (@tables) {
510      iptables $ip_version, t => $table, F => '';
511      iptables $ip_version, t => $table, X => '';
512    }
513  }
514
515  for my $p (qw/INPUT FORWARD OUTPUT/) {
516    iptables $ip_version, P => $p, ["ACCEPT"];
517  }
518
519}
520
521sub _open_or_close_port {
522  my ( $ip_version, $dev_type, $push_type, $chain, $jump, $port, $option ) = @_;
523
524  my @opts;
525
526  push( @opts, "t", "filter", "$push_type", "$chain" );
527
528  unless ( exists $option->{"dev"} ) {
529    my $net_info = network_interfaces();
530    my @dev      = keys %{$net_info};
531    $option->{"dev"} = \@dev;
532  }
533
534  if ( exists $option->{"dev"} && !ref( $option->{"dev"} ) ) {
535    push( @opts, "$dev_type", $option->{"dev"} );
536  }
537  elsif ( ref( $option->{"dev"} ) eq "ARRAY" ) {
538    for my $dev ( @{ $option->{"dev"} } ) {
539      my $new_option = $option;
540      $new_option->{"dev"} = $dev;
541
542      _open_or_close_port( $ip_version, $dev_type, $push_type, $chain, $jump,
543        $port, $new_option );
544    }
545
546    return;
547  }
548
549  if ( exists $option->{"proto"} ) {
550    push( @opts, "p", $option->{"proto"} );
551    push( @opts, "m", $option->{"proto"} );
552  }
553  else {
554    push( @opts, "p", "tcp" );
555    push( @opts, "m", "tcp" );
556  }
557
558  if ( $port eq "all" ) {
559    push( @opts, "j", "$jump" );
560  }
561  else {
562    if ( ref($port) eq "ARRAY" ) {
563      for my $port_num ( @{$port} ) {
564        _open_or_close_port( $ip_version, $dev_type, $push_type, $chain, $jump,
565          $port_num, $option );
566      }
567      return;
568    }
569
570    push( @opts, "dport", $port );
571    push( @opts, "j",     $jump );
572  }
573
574  if ( _rule_exists( $ip_version, @opts ) ) {
575    Rex::Logger::debug("iptables rule already exists. skipping...");
576    return;
577  }
578
579  iptables $ip_version, @opts;
580
581}
582
583sub _rule_exists {
584  my ( $ip_version, @check_rule ) = @_;
585
586  if ( $check_rule[0] eq "t" ) {
587    shift @check_rule;
588    shift @check_rule;
589  }
590
591  if ( $check_rule[0] eq "D" || $check_rule[0] eq "A" ) {
592    shift @check_rule;
593  }
594
595  my $str_check_rule = join( " ", "A", @check_rule );
596
597  my $current_tables = iptables_list($ip_version);
598  if ( exists $current_tables->{filter} ) {
599    for my $rule ( @{ $current_tables->{filter} } ) {
600      my $str_rule = join( " ", @{$rule} );
601      $str_rule =~ s/\s$//;
602
603      Rex::Logger::debug("comparing: '$str_rule' == '$str_check_rule'");
604      if ( $str_rule eq $str_check_rule ) {
605        return 1;
606      }
607    }
608  }
609
610  return 0;
611}
612
613sub _get_ip_version {
614  my ($params) = @_;
615  if ( defined $params->[0] && !ref $params->[0] ) {
616    if ( $params->[0] eq "-4" || $params->[0] eq "-6" ) {
617      return shift @$params;
618    }
619  }
620  return -4;
621}
622
623sub _get_executable {
624  my ($params)       = @_;
625  my $ip_version     = _get_ip_version($params);
626  my $cache          = Rex::get_cache();
627  my $cache_key_name = "iptables.$ip_version.executable";
628  return $cache->get($cache_key_name) if $cache->valid($cache_key_name);
629
630  my $binary     = $ip_version == -6 ? "ip6tables" : "iptables";
631  my $executable = can_run($binary);
632  die "Can't find $binary in PATH" if $executable eq '';
633  $cache->set( $cache_key_name, $executable );
634
635  return $executable;
636}
637
638sub _iptables_version {
639  my @params         = @_;
640  my $ip_version     = _get_ip_version( \@params );
641  my $cache          = Rex::get_cache();
642  my $cache_key_name = "iptables.$ip_version.version";
643  return version->parse( $cache->get($cache_key_name) )
644    if $cache->valid($cache_key_name);
645
646  my $iptables = _get_executable( \@params );
647  my $out      = i_run( "$iptables -V", fail_ok => 1 );
648  if ( $out =~ /v([.\d]+)/ms ) {
649    my $version = version->parse($1);
650    $cache->set( $cache_key_name, "$version" );
651    return $version;
652  }
653  else {
654    die "Can't parse `$iptables -V' output `$out'";
655  }
656}
657
6581;
659