fwtester: implement some useful command line option
[pve-firewall.git] / test / fwtester.pl
1 #!/usr/bin/perl
2
3 use lib '../src';
4 use strict;
5 use warnings;
6 use Data::Dumper;
7 use PVE::Firewall;
8 use Getopt::Long;
9 use File::Basename;
10
11 my $mark;
12 my $trace;
13
14 my $outside_iface = 'eth0';
15 my $outside_bridge = 'vmbr0';
16
17 my $debug = 0;
18
19 sub print_usage_and_exit {
20     die "usage: $0 [--debug] [testfile [testid]]\n";
21 }
22
23 if (!GetOptions ('debug' => \$debug)) {
24     print_usage_and_exit();
25 }
26
27 my $testfilename = shift;
28 my $testid = shift;
29
30 sub add_trace {
31     my ($text) = @_;
32
33     if ($debug) {
34         print $text;
35     } else {
36         $trace .= $text;
37     }
38 }
39
40 sub rule_match {
41     my ($chain, $rule, $pkg) = @_;
42
43     $rule =~ s/^-A $chain // || die "got strange rule: $rule";
44
45     if ($rule =~ s/^-m conntrack\s*//) {
46         return undef; # simply ignore
47     }
48
49     if ($rule =~ s/^-m addrtype\s*//) {
50         return undef; # simply ignore
51     }
52
53     if ($rule =~ s/^-i (\S+)\s*//) {
54         die "missing iface_in" if !$pkg->{iface_in};
55         return undef if $pkg->{iface_in} ne $1; # no match
56     }
57     if ($rule =~ s/^-o (\S+)\s*//) {
58         die "missing iface_out" if !$pkg->{iface_out};
59         return undef if $pkg->{iface_out} ne $1; # no match
60     }
61
62     if ($rule =~ s/^-p (tcp|udp)\s*//) {
63         die "missing proto" if !$pkg->{proto};
64         return undef if $pkg->{proto} ne $1; # no match
65     }
66
67     if ($rule =~ s/^--dport (\d+):(\d+)\s*//) {
68         die "missing dport" if !$pkg->{dport};
69         return undef if ($pkg->{dport} < $1) || ($pkg->{dport} > $2); # no match
70     }
71
72     if ($rule =~ s/^--dport (\d+)\s*//) {
73         die "missing dport" if !$pkg->{dport};
74         return undef if $pkg->{dport} != $1; # no match
75     }
76
77     if ($rule =~ s/^-s (\S+)\s*//) {
78         die "missing source" if !$pkg->{source};
79         return undef if $pkg->{source} ne $1; # no match
80     }
81     
82     if ($rule =~ s/^-d (\S+)\s*//) {
83         die "missing destination" if !$pkg->{dest};
84         return undef if $pkg->{dest} ne $1; # no match
85     }
86
87     if ($rule =~ s/^-m mac ! --mac-source (\S+)\s*//) {
88         die "missing source mac" if !$pkg->{mac_source};
89         return undef if $pkg->{mac_source} eq $1; # no match
90     }
91
92     if ($rule =~ s/^-m physdev --physdev-is-bridged --physdev-in (\S+)\s*//) {
93         my $devre = $1;
94         $devre =~ s/\+/\.\*/;
95         return undef if !$pkg->{physdev_in};
96         return undef if $pkg->{physdev_in} !~ m/^${devre}$/;
97     }
98
99     if ($rule =~ s/^-m physdev --physdev-is-bridged --physdev-out (\S+)\s*//) {
100         my $devre = $1;
101         $devre =~ s/\+/\.\*/;
102         return undef if !$pkg->{physdev_out};
103         return undef if $pkg->{physdev_out} !~ m/^${devre}$/;
104     }
105
106     if ($rule =~ s/^-j MARK --set-mark (\d+)\s*$//) {
107         $mark = $1;
108         return undef;
109     }
110
111     if ($rule =~ s/^-j (\S+)\s*$//) {
112         return (0, $1);
113     }
114
115     if ($rule =~ s/^-g (\S+)\s*$//) {
116         return (1, $1);
117     }
118
119     die "unable to parse rule: $rule";
120 }
121
122 sub ruleset_simulate_chain {
123     my ($ruleset, $chain, $pkg) = @_;
124
125     add_trace("ENTER chain $chain\n");
126     
127     if ($chain eq 'PVEFW-Drop') {
128         add_trace("LEAVE chain $chain\n");
129         return 'DROP';
130     }
131     if ($chain eq 'PVEFW-reject') {
132         add_trace("LEAVE chain $chain\n");
133         return 'REJECT';
134     }
135
136     if ($chain eq 'PVEFW-tcpflags') {
137         add_trace("LEAVE chain $chain\n");
138         return undef;
139     }
140
141     my $rules = $ruleset->{$chain} ||
142         die "no such chain '$chain'";
143
144     foreach my $rule (@$rules) {
145         my ($goto, $action) = rule_match($chain, $rule, $pkg);
146         if (!defined($action)) {
147             add_trace("SKIP: $rule\n");
148             next;
149         }
150         add_trace("MATCH: $rule\n");
151         
152         if ($action eq 'ACCEPT' || $action eq 'DROP' || $action eq 'REJECT') {
153             add_trace("TERMINATE chain $chain: $action\n");
154             return $action;
155         } elsif ($action eq 'RETURN') {
156             add_trace("RETURN FROM chain $chain\n");
157             last;
158         } else {
159             if ($goto) {
160                 add_trace("LEAVE chain $chain - goto $action\n");
161                 return ruleset_simulate_chain($ruleset, $action, $pkg)
162                 #$chain = $action;
163                 #$rules = $ruleset->{$chain} || die "no such chain '$chain'";
164             } else {
165                 if ($action = ruleset_simulate_chain($ruleset, $action, $pkg)) {
166                     return $action;
167                 }
168                 add_trace("CONTINUE chain $chain\n");
169             }
170         }
171     }
172
173     add_trace("LEAVE chain $chain\n");
174     if ($chain =~ m/^PVEFW-(INPUT|OUTPUT|FORWARD)$/) {
175         return 'ACCEPT'; # default policy
176     }
177
178     return undef;
179 }
180
181 sub copy_packet {
182     my ($pkg) = @_;
183
184     my $res = {};
185
186     while (my ($k,$v) = each %$pkg) {
187         $res->{$k} = $v;
188     }
189
190     return $res;
191 }
192
193 # Try to simulate packet traversal inside kernel. This invokes iptable
194 # checks several times.
195 sub route_packet {
196     my ($ruleset, $ipset_ruleset, $pkg, $from_info, $target, $start_state) = @_;
197
198     my $route_state = $start_state;
199
200     my $physdev_in;
201
202     while ($route_state ne $target->{iface}) {
203
204         my $chain;
205         my $next_route_state;
206         my $next_physdev_in;
207
208         $pkg->{iface_in} = $pkg->{iface_out} = undef;
209         $pkg->{physdev_in} = $pkg->{physdev_out} = undef;
210
211         if ($route_state eq 'from-outside') {
212             $next_route_state = $outside_bridge || die 'internal error';
213             $next_physdev_in = $outside_iface || die 'internal error';
214         } elsif ($route_state eq 'host') {
215
216             if ($target->{type} eq 'outside') {
217                 $pkg->{iface_in} = 'lo';
218                 $pkg->{iface_out} = $outside_bridge;
219                 $chain = 'PVEFW-OUTPUT';
220                 $next_route_state = $outside_iface
221             } elsif ($target->{type} eq 'ct') {
222                 $pkg->{iface_in} = 'lo';
223                 $pkg->{iface_out} = 'venet0';
224                 $chain = 'PVEFW-OUTPUT';
225                 $next_route_state = 'venet-in';
226             } elsif ($target->{type} eq 'vm') {
227                 $pkg->{iface_in} = 'lo';
228                 $pkg->{iface_out} = $target->{bridge} || die 'internal error';
229                 $chain = 'PVEFW-OUTPUT';
230                 $next_route_state = 'fwbr-in';
231             } else {
232                 die "implement me";
233             }
234
235         } elsif ($route_state eq 'venet-out') {
236
237             if ($target->{type} eq 'host') {
238
239                 $chain = 'PVEFW-INPUT';
240                 $pkg->{iface_in} = 'venet0';
241                 $pkg->{iface_out} = 'lo';
242                 $next_route_state = 'host';
243
244             } elsif ($target->{type} eq 'outside') {
245                 
246                 $chain = 'PVEFW-FORWARD';
247                 $pkg->{iface_in} = 'venet0';
248                 $pkg->{iface_out} = $outside_bridge;
249                 $next_route_state = $outside_iface;
250
251             } elsif ($target->{type} eq 'vm') {
252
253                 $chain = 'PVEFW-FORWARD';
254                 $pkg->{iface_in} = 'venet0';
255                 $pkg->{iface_out} = $target->{bridge} || die 'internal error';
256                 $next_route_state = 'fwbr-in';
257
258             } elsif ($target->{type} eq 'ct') {
259
260                 $chain = 'PVEFW-FORWARD';
261                 $pkg->{iface_in} = 'venet0';
262                 $pkg->{iface_out} = 'venet0';
263                 $next_route_state = 'venet-in';
264
265             } else {
266                 die "implement me";
267             }
268
269         } elsif ($route_state eq 'fwbr-out') {
270
271             $chain = 'PVEFW-FORWARD';
272             $next_route_state = $from_info->{bridge} || die 'internal error';
273             $next_physdev_in = $from_info->{fwpr} || die 'internal error';
274             $pkg->{iface_in} = $from_info->{fwbr} || die 'internal error';
275             $pkg->{iface_out} = $from_info->{fwbr} || die 'internal error';
276             $pkg->{physdev_in} = $from_info->{tapdev} || die 'internal error';
277             $pkg->{physdev_out} = $from_info->{fwln} || die 'internal error';
278         
279         } elsif ($route_state eq 'fwbr-in') {
280
281             $chain = 'PVEFW-FORWARD';
282             $next_route_state = $target->{tapdev};
283             $pkg->{iface_in} = $target->{fwbr} || die 'internal error';
284             $pkg->{iface_out} = $target->{fwbr} || die 'internal error';
285             $pkg->{physdev_in} = $target->{fwln} || die 'internal error';
286             $pkg->{physdev_out} = $target->{tapdev} || die 'internal error';
287
288         } elsif ($route_state =~ m/^vmbr\d+$/) {
289             
290             die "missing physdev_in - internal error?" if !$physdev_in;
291
292             if ($target->{type} eq 'host') {
293
294                 $chain = 'PVEFW-INPUT';
295                 $pkg->{iface_in} = $route_state;
296                 $pkg->{iface_out} = 'lo';
297                 $next_route_state = 'host';
298
299                 if ($route_state eq $outside_bridge) {
300
301                 } else {
302
303                 }
304
305             } elsif ($target->{type} eq 'outside') {
306
307                 $chain = 'PVEFW-FORWARD';
308                 $pkg->{iface_in} = $route_state;
309                 $pkg->{iface_out} = $outside_bridge;
310                 $pkg->{physdev_in} = $physdev_in;
311                 # conditionally set physdev_out (same behavior as kernel)
312                 if ($route_state eq $outside_bridge) {
313                     $pkg->{physdev_out} = $outside_iface || die 'internal error';
314                 }
315                 $next_route_state = $outside_iface;
316
317             } elsif ($target->{type} eq 'ct') {
318
319                 $chain = 'PVEFW-FORWARD';
320                 $pkg->{iface_in} = $route_state;
321                 $pkg->{iface_out} = 'venet0';
322                 $next_route_state = 'venet-in';
323
324             } elsif ($target->{type} eq 'vm') {
325
326                 $chain = 'PVEFW-FORWARD';
327                 $pkg->{iface_in} = $route_state;
328                 $pkg->{iface_out} = $target->{bridge};
329                 $pkg->{physdev_in} = $physdev_in;
330                 # conditionally set physdev_out (same behavior as kernel)
331                 if ($route_state eq $target->{bridge}) {
332                     $pkg->{physdev_out} = $target->{fwpr} || die 'internal error';
333                 }
334                 $next_route_state = 'fwbr-in';
335
336             } else {
337                 die "implement me";
338             }
339
340         } else {
341             die "implement me $route_state";
342         }
343
344         die "internal error" if !defined($next_route_state);
345
346         if ($chain) {
347             add_trace("IPT check at $route_state (chain $chain)\n");
348             add_trace(Dumper($pkg));
349             my $res = ruleset_simulate_chain($ruleset, $chain, $pkg);
350             return $res if $res ne 'ACCEPT';
351         } 
352
353         $route_state = $next_route_state;
354
355         $physdev_in = $next_physdev_in;
356     }
357
358     return 'ACCEPT';
359 }
360
361 sub extract_ct_info {
362     my ($vmdata, $vmid) = @_;
363
364     my $info = { type => 'ct', vmid => $vmid };
365
366     my $conf = $vmdata->{openvz}->{$vmid} || die "no such CT '$vmid'";
367     if ($conf->{ip_address}) {
368         $info->{ip_address} = $conf->{ip_address}->{value};
369     } else {
370         die "implement me";
371     }
372     return $info;
373 }
374
375 sub extract_vm_info {
376     my ($vmdata, $vmid) = @_;
377
378     my $info = { type => 'vm', vmid => $vmid };
379
380     my $conf = $vmdata->{qemu}->{$vmid} || die "no such VM '$vmid'";
381     my $net = PVE::QemuServer::parse_net($conf->{net0});
382     $info->{macaddr} = $net->{macaddr} || die "unable to get mac address";
383     $info->{bridge} = $net->{bridge} || die "unable to get bridge";
384     $info->{fwbr} = "fwbr${vmid}i0";
385     $info->{tapdev} = "tap${vmid}i0";
386     $info->{fwln} = "fwln${vmid}i0";
387     $info->{fwpr} = "fwpr${vmid}p0";
388
389     return $info;
390 }
391
392 sub simulate_firewall {
393     my ($ruleset, $ipset_ruleset, $vmdata, $test) = @_;
394
395     my $from = delete $test->{from} || die "missing 'from' field";
396     my $to = delete $test->{to} || die "missing 'to' field";
397     my $action = delete $test->{action} || die "missing 'action'";
398
399     die "from/to needs to be different" if $from eq $to;
400
401     my $pkg = {
402         proto => 'tcp',
403         sport => '1234',
404         dport => '4321',
405         source => '10.11.12.13',
406         dest => '10.11.12.14',
407     };
408
409     while (my ($k,$v) = each %$test) {
410         $pkg->{$k} = $v;
411     }
412
413     my $from_info = {};
414
415     my $start_state;
416
417     if ($from eq 'host') {
418         $from_info->{type} = 'host';
419         $start_state = 'host';
420     } elsif ($from eq 'outside') {
421         $from_info->{type} = 'outside';
422         $start_state = 'from-outside';
423     } elsif ($from =~ m/^ct(\d+)$/) {
424         my $vmid = $1;
425         $from_info = extract_ct_info($vmdata, $vmid);
426         if ($from_info->{ip_address}) {
427             $pkg->{source} = $from_info->{ip_address};
428             $start_state = 'venet-out';
429         } else {
430             die "implement me";
431         }
432     } elsif ($from =~ m/^vm(\d+)$/) {
433         my $vmid = $1;
434         $from_info = extract_vm_info($vmdata, $vmid);
435         $start_state = 'fwbr-out'; 
436         $pkg->{mac_source} = $from_info->{macaddr};
437     } else {
438         die "implement me";
439     }
440
441     my $target;
442
443     if ($to eq 'host') {
444         $target->{type} = 'host';
445         $target->{iface} = 'host';
446     } elsif ($to eq 'outside') {
447         $target->{type} = 'outside';
448         $target->{iface} = $outside_iface;
449     } elsif ($to =~ m/^ct(\d+)$/) {
450         my $vmid = $1;
451         $target = extract_ct_info($vmdata, $vmid);
452         $target->{iface} = 'venet-in';
453
454         if ($target->{ip_address}) {
455             $pkg->{dest} = $target->{ip_address};
456         } else {
457             die "implement me";
458         }
459    } elsif ($to =~ m/^vm(\d+)$/) {
460         my $vmid = $1;
461         $target = extract_vm_info($vmdata, $vmid);
462         $target->{iface} = $target->{tapdev};
463     } else {
464         die "implement me";
465     }
466
467     my $res = route_packet($ruleset, $ipset_ruleset, $pkg, $from_info, $target, $start_state);
468
469     die "test failed ($res != $action)\n" if $action ne $res;
470
471     return undef; 
472 }
473
474 sub run_tests {
475     my ($vmdata, $testdir, $testfile, $testid) = @_;
476
477     $testfile = 'tests' if !$testfile;
478
479     $vmdata->{testdir} = $testdir;
480
481     my ($ruleset, $ipset_ruleset) = 
482         PVE::Firewall::compile(undef, undef, $vmdata);
483
484     my $filename = "$testdir/$testfile";
485     my $fh = IO::File->new($filename) ||
486         die "unable to open '$filename' - $!\n";
487
488     my $testcount = 0;
489     while (defined(my $line = <$fh>)) {
490         next if $line =~ m/^\s*$/;
491         next if $line =~ m/^#.*$/;
492         if ($line =~ m/^\{.*\}\s*$/) {
493             my $test = eval $line;
494             die $@ if $@;
495             next if defined($testid) && (!defined($test->{id}) || ($testid ne $test->{id}));
496             $trace = '';
497             print Dumper($ruleset) if $debug;
498             $testcount++;
499             eval { simulate_firewall($ruleset, $ipset_ruleset, $vmdata, $test); };
500             if (my $err = $@) {
501
502                 print Dumper($ruleset) if !$debug;
503
504                 print "$trace\n" if !$debug;
505
506                 print "$filename line $.: $line";
507
508                 print "test failed: $err\n";
509
510                 exit(-1);
511             }
512         } else {
513             die "parse error";
514         }
515     }
516
517     die "no tests found\n" if $testcount <= 0;
518
519     print "PASS: $filename\n";
520
521     return undef;
522 }
523
524 my $vmdata = {
525     qemu => {
526         100 => {
527             net0 => "e1000=0E:0B:38:B8:B3:21,bridge=vmbr0",
528         },
529         101 => {
530             net0 => "e1000=0E:0B:38:B8:B3:22,bridge=vmbr0",
531         },
532         # on bridge vmbr1
533         110 => {
534             net0 => "e1000=0E:0B:38:B8:B4:21,bridge=vmbr1",
535         },
536     },
537     openvz => {
538         200 => {
539             ip_address => { value => '10.0.200.1' },
540         },
541         201 => {
542             ip_address => { value => '10.0.200.2' },
543         },
544     },
545 };
546
547 if ($testfilename) {
548     my $testfile;
549     my $dir;
550
551     if (-d $testfilename) {
552         $dir = $testfilename;
553     } elsif (-f $testfilename) {
554         $dir = dirname($testfilename);
555         $testfile = basename($testfilename);
556     } else {
557         die "no such file/dir '$testfilename'\n"; 
558     }
559
560     run_tests($vmdata, $dir, $testfile, $testid);
561
562 } else { 
563     foreach my $dir (<test-*>) {
564         next if ! -d $dir;
565         run_tests($vmdata, $dir);
566     }
567 }
568
569 print "OK - all tests passed\n";
570
571 exit(0);