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