fwtester: add counters for debugging
[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     my $counter = 2; # ENTER + LEAVE = 2
128
129     if ($chain eq 'PVEFW-Drop') {
130         add_trace("LEAVE chain $chain\n");
131         return ('DROP', $counter);
132     }
133     if ($chain eq 'PVEFW-reject') {
134         add_trace("LEAVE chain $chain\n");
135         return ('REJECT', $counter);
136     }
137
138     if ($chain eq 'PVEFW-tcpflags') {
139         add_trace("LEAVE chain $chain\n");
140         return (undef, $counter);
141     }
142
143     my $rules = $ruleset->{$chain} ||
144         die "no such chain '$chain'";
145
146     foreach my $rule (@$rules) {
147         $counter++;
148         my ($goto, $action) = rule_match($chain, $rule, $pkg);
149         if (!defined($action)) {
150             add_trace("SKIP: $rule\n");
151             next;
152         }
153         add_trace("MATCH: $rule\n");
154         
155         if ($action eq 'ACCEPT' || $action eq 'DROP' || $action eq 'REJECT') {
156             add_trace("TERMINATE chain $chain: $action\n");
157             return ($action, $counter);
158         } elsif ($action eq 'RETURN') {
159             add_trace("RETURN FROM chain $chain\n");
160             last;
161         } else {
162             if ($goto) {
163                 add_trace("LEAVE chain $chain - goto $action\n");
164                 return ruleset_simulate_chain($ruleset, $action, $pkg)
165                 #$chain = $action;
166                 #$rules = $ruleset->{$chain} || die "no such chain '$chain'";
167             } else {
168                 my ($act, $ctr) = ruleset_simulate_chain($ruleset, $action, $pkg);
169                 $counter += $ctr;
170                 return ($act, $counter) if $act;
171                 add_trace("CONTINUE chain $chain\n");
172             }
173         }
174     }
175
176     add_trace("LEAVE chain $chain\n");
177     if ($chain =~ m/^PVEFW-(INPUT|OUTPUT|FORWARD)$/) {
178         return ('ACCEPT', $counter); # default policy
179     }
180
181     return (undef, $counter);
182 }
183
184 sub copy_packet {
185     my ($pkg) = @_;
186
187     my $res = {};
188
189     while (my ($k,$v) = each %$pkg) {
190         $res->{$k} = $v;
191     }
192
193     return $res;
194 }
195
196 # Try to simulate packet traversal inside kernel. This invokes iptable
197 # checks several times.
198 sub route_packet {
199     my ($ruleset, $ipset_ruleset, $pkg, $from_info, $target, $start_state) = @_;
200
201     my $route_state = $start_state;
202
203     my $physdev_in;
204
205     my $ipt_invocation_counter = 0;
206     my $rule_check_counter = 0;
207
208     while ($route_state ne $target->{iface}) {
209
210         my $chain;
211         my $next_route_state;
212         my $next_physdev_in;
213
214         $pkg->{iface_in} = $pkg->{iface_out} = undef;
215         $pkg->{physdev_in} = $pkg->{physdev_out} = undef;
216
217         if ($route_state eq 'from-outside') {
218             $next_route_state = $outside_bridge || die 'internal error';
219             $next_physdev_in = $outside_iface || die 'internal error';
220         } elsif ($route_state eq 'host') {
221
222             if ($target->{type} eq 'outside') {
223                 $pkg->{iface_in} = 'lo';
224                 $pkg->{iface_out} = $outside_bridge;
225                 $chain = 'PVEFW-OUTPUT';
226                 $next_route_state = $outside_iface
227             } elsif ($target->{type} eq 'ct') {
228                 $pkg->{iface_in} = 'lo';
229                 $pkg->{iface_out} = 'venet0';
230                 $chain = 'PVEFW-OUTPUT';
231                 $next_route_state = 'venet-in';
232             } elsif ($target->{type} eq 'vm') {
233                 $pkg->{iface_in} = 'lo';
234                 $pkg->{iface_out} = $target->{bridge} || die 'internal error';
235                 $chain = 'PVEFW-OUTPUT';
236                 $next_route_state = 'fwbr-in';
237             } else {
238                 die "implement me";
239             }
240
241         } elsif ($route_state eq 'venet-out') {
242
243             if ($target->{type} eq 'host') {
244
245                 $chain = 'PVEFW-INPUT';
246                 $pkg->{iface_in} = 'venet0';
247                 $pkg->{iface_out} = 'lo';
248                 $next_route_state = 'host';
249
250             } elsif ($target->{type} eq 'outside') {
251                 
252                 $chain = 'PVEFW-FORWARD';
253                 $pkg->{iface_in} = 'venet0';
254                 $pkg->{iface_out} = $outside_bridge;
255                 $next_route_state = $outside_iface;
256
257             } elsif ($target->{type} eq 'vm') {
258
259                 $chain = 'PVEFW-FORWARD';
260                 $pkg->{iface_in} = 'venet0';
261                 $pkg->{iface_out} = $target->{bridge} || die 'internal error';
262                 $next_route_state = 'fwbr-in';
263
264             } elsif ($target->{type} eq 'ct') {
265
266                 $chain = 'PVEFW-FORWARD';
267                 $pkg->{iface_in} = 'venet0';
268                 $pkg->{iface_out} = 'venet0';
269                 $next_route_state = 'venet-in';
270
271             } else {
272                 die "implement me";
273             }
274
275         } elsif ($route_state eq 'fwbr-out') {
276
277             $chain = 'PVEFW-FORWARD';
278             $next_route_state = $from_info->{bridge} || die 'internal error';
279             $next_physdev_in = $from_info->{fwpr} || die 'internal error';
280             $pkg->{iface_in} = $from_info->{fwbr} || die 'internal error';
281             $pkg->{iface_out} = $from_info->{fwbr} || die 'internal error';
282             $pkg->{physdev_in} = $from_info->{tapdev} || die 'internal error';
283             $pkg->{physdev_out} = $from_info->{fwln} || die 'internal error';
284         
285         } elsif ($route_state eq 'fwbr-in') {
286
287             $chain = 'PVEFW-FORWARD';
288             $next_route_state = $target->{tapdev};
289             $pkg->{iface_in} = $target->{fwbr} || die 'internal error';
290             $pkg->{iface_out} = $target->{fwbr} || die 'internal error';
291             $pkg->{physdev_in} = $target->{fwln} || die 'internal error';
292             $pkg->{physdev_out} = $target->{tapdev} || die 'internal error';
293
294         } elsif ($route_state =~ m/^vmbr\d+$/) {
295             
296             die "missing physdev_in - internal error?" if !$physdev_in;
297
298             if ($target->{type} eq 'host') {
299
300                 $chain = 'PVEFW-INPUT';
301                 $pkg->{iface_in} = $route_state;
302                 $pkg->{iface_out} = 'lo';
303                 $next_route_state = 'host';
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             $ipt_invocation_counter++;
350             my ($res, $ctr) = ruleset_simulate_chain($ruleset, $chain, $pkg);
351             $rule_check_counter += $ctr;
352             return ($res, $ipt_invocation_counter, $rule_check_counter) if $res ne 'ACCEPT';
353         } 
354
355         $route_state = $next_route_state;
356
357         $physdev_in = $next_physdev_in;
358     }
359
360     return ('ACCEPT', $ipt_invocation_counter, $rule_check_counter);
361 }
362
363 sub extract_ct_info {
364     my ($vmdata, $vmid) = @_;
365
366     my $info = { type => 'ct', vmid => $vmid };
367
368     my $conf = $vmdata->{openvz}->{$vmid} || die "no such CT '$vmid'";
369     if ($conf->{ip_address}) {
370         $info->{ip_address} = $conf->{ip_address}->{value};
371     } else {
372         die "implement me";
373     }
374     return $info;
375 }
376
377 sub extract_vm_info {
378     my ($vmdata, $vmid) = @_;
379
380     my $info = { type => 'vm', vmid => $vmid };
381
382     my $conf = $vmdata->{qemu}->{$vmid} || die "no such VM '$vmid'";
383     my $net = PVE::QemuServer::parse_net($conf->{net0});
384     $info->{macaddr} = $net->{macaddr} || die "unable to get mac address";
385     $info->{bridge} = $net->{bridge} || die "unable to get bridge";
386     $info->{fwbr} = "fwbr${vmid}i0";
387     $info->{tapdev} = "tap${vmid}i0";
388     $info->{fwln} = "fwln${vmid}i0";
389     $info->{fwpr} = "fwpr${vmid}p0";
390
391     return $info;
392 }
393
394 sub simulate_firewall {
395     my ($ruleset, $ipset_ruleset, $vmdata, $test) = @_;
396
397     my $from = delete $test->{from} || die "missing 'from' field";
398     my $to = delete $test->{to} || die "missing 'to' field";
399     my $action = delete $test->{action} || die "missing 'action'";
400     
401     my $testid = delete $test->{id};
402     
403     die "from/to needs to be different" if $from eq $to;
404
405     my $pkg = {
406         proto => 'tcp',
407         sport => undef,
408         dport => undef,
409         source => undef,
410         dest => undef,
411     };
412
413     while (my ($k,$v) = each %$test) {
414         die "unknown attribute '$k'\n" if !exists($pkg->{$k});
415         $pkg->{$k} = $v;
416     }
417
418     my $from_info = {};
419
420     my $start_state;
421
422     if ($from eq 'host') {
423         $from_info->{type} = 'host';
424         $start_state = 'host';
425     } elsif ($from eq 'outside') {
426         $from_info->{type} = 'outside';
427         $start_state = 'from-outside';
428     } elsif ($from =~ m/^ct(\d+)$/) {
429         my $vmid = $1;
430         $from_info = extract_ct_info($vmdata, $vmid);
431         if ($from_info->{ip_address}) {
432             $pkg->{source} = $from_info->{ip_address};
433             $start_state = 'venet-out';
434         } else {
435             die "implement me";
436         }
437     } elsif ($from =~ m/^vm(\d+)$/) {
438         my $vmid = $1;
439         $from_info = extract_vm_info($vmdata, $vmid);
440         $start_state = 'fwbr-out'; 
441         $pkg->{mac_source} = $from_info->{macaddr};
442     } else {
443         die "implement me";
444     }
445
446     my $target;
447
448     if ($to eq 'host') {
449         $target->{type} = 'host';
450         $target->{iface} = 'host';
451     } elsif ($to eq 'outside') {
452         $target->{type} = 'outside';
453         $target->{iface} = $outside_iface;
454     } elsif ($to =~ m/^ct(\d+)$/) {
455         my $vmid = $1;
456         $target = extract_ct_info($vmdata, $vmid);
457         $target->{iface} = 'venet-in';
458
459         if ($target->{ip_address}) {
460             $pkg->{dest} = $target->{ip_address};
461         } else {
462             die "implement me";
463         }
464    } elsif ($to =~ m/^vm(\d+)$/) {
465         my $vmid = $1;
466         $target = extract_vm_info($vmdata, $vmid);
467         $target->{iface} = $target->{tapdev};
468     } else {
469         die "implement me";
470     }
471
472     my ($res, $ic, $rc) = route_packet($ruleset, $ipset_ruleset, $pkg, 
473                                        $from_info, $target, $start_state);
474
475     add_trace("IPT statistics: invocation = $ic, checks = $rc\n");
476  
477     die "test failed ($res != $action)\n" if $action ne $res;
478
479     return undef; 
480 }
481
482 sub run_tests {
483     my ($vmdata, $testdir, $testfile, $testid) = @_;
484
485     $testfile = 'tests' if !$testfile;
486
487     $vmdata->{testdir} = $testdir;
488
489     my ($ruleset, $ipset_ruleset) = 
490         PVE::Firewall::compile(undef, undef, $vmdata);
491
492     my $filename = "$testdir/$testfile";
493     my $fh = IO::File->new($filename) ||
494         die "unable to open '$filename' - $!\n";
495
496     my $testcount = 0;
497     while (defined(my $line = <$fh>)) {
498         next if $line =~ m/^\s*$/;
499         next if $line =~ m/^#.*$/;
500         if ($line =~ m/^\{.*\}\s*$/) {
501             my $test = eval $line;
502             die $@ if $@;
503             next if defined($testid) && (!defined($test->{id}) || ($testid ne $test->{id}));
504             $trace = '';
505             print Dumper($ruleset) if $debug;
506             $testcount++;
507             eval { simulate_firewall($ruleset, $ipset_ruleset, $vmdata, $test); };
508             if (my $err = $@) {
509
510                 print Dumper($ruleset) if !$debug;
511
512                 print "$trace\n" if !$debug;
513
514                 print "$filename line $.: $line";
515
516                 print "test failed: $err\n";
517
518                 exit(-1);
519             }
520         } else {
521             die "parse error";
522         }
523     }
524
525     die "no tests found\n" if $testcount <= 0;
526
527     print "PASS: $filename\n";
528
529     return undef;
530 }
531
532 my $vmdata = {
533     qemu => {
534         100 => {
535             net0 => "e1000=0E:0B:38:B8:B3:21,bridge=vmbr0",
536         },
537         101 => {
538             net0 => "e1000=0E:0B:38:B8:B3:22,bridge=vmbr0",
539         },
540         # on bridge vmbr1
541         110 => {
542             net0 => "e1000=0E:0B:38:B8:B4:21,bridge=vmbr1",
543         },
544     },
545     openvz => {
546         200 => {
547             ip_address => { value => '10.0.200.1' },
548         },
549         201 => {
550             ip_address => { value => '10.0.200.2' },
551         },
552     },
553 };
554
555 if ($testfilename) {
556     my $testfile;
557     my $dir;
558
559     if (-d $testfilename) {
560         $dir = $testfilename;
561     } elsif (-f $testfilename) {
562         $dir = dirname($testfilename);
563         $testfile = basename($testfilename);
564     } else {
565         die "no such file/dir '$testfilename'\n"; 
566     }
567
568     run_tests($vmdata, $dir, $testfile, $testid);
569
570 } else { 
571     foreach my $dir (<test-*>) {
572         next if ! -d $dir;
573         run_tests($vmdata, $dir);
574     }
575 }
576
577 print "OK - all tests passed\n";
578
579 exit(0);