fwtester: fix emulation - correctly set phydev_in
[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             $pkg->{physdev_in} = $physdev_in;
298
299             if ($target->{type} eq 'host') {
300
301                 $chain = 'PVEFW-INPUT';
302                 $pkg->{iface_in} = $route_state;
303                 $pkg->{iface_out} = 'lo';
304                 $next_route_state = 'host';
305
306             } elsif ($target->{type} eq 'outside') {
307
308                 $chain = 'PVEFW-FORWARD';
309                 $pkg->{iface_in} = $route_state;
310                 $pkg->{iface_out} = $outside_bridge;
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                 # conditionally set physdev_out (same behavior as kernel)
330                 if ($route_state eq $target->{bridge}) {
331                     $pkg->{physdev_out} = $target->{fwpr} || die 'internal error';
332                 }
333                 $next_route_state = 'fwbr-in';
334
335             } else {
336                 die "implement me";
337             }
338
339         } else {
340             die "implement me $route_state";
341         }
342
343         die "internal error" if !defined($next_route_state);
344
345         if ($chain) {
346             add_trace("IPT check at $route_state (chain $chain)\n");
347             add_trace(Dumper($pkg));
348             $ipt_invocation_counter++;
349             my ($res, $ctr) = ruleset_simulate_chain($ruleset, $chain, $pkg);
350             $rule_check_counter += $ctr;
351             return ($res, $ipt_invocation_counter, $rule_check_counter) if $res ne 'ACCEPT';
352         } 
353
354         $route_state = $next_route_state;
355
356         $physdev_in = $next_physdev_in;
357     }
358
359     return ('ACCEPT', $ipt_invocation_counter, $rule_check_counter);
360 }
361
362 sub extract_ct_info {
363     my ($vmdata, $vmid) = @_;
364
365     my $info = { type => 'ct', vmid => $vmid };
366
367     my $conf = $vmdata->{openvz}->{$vmid} || die "no such CT '$vmid'";
368     if ($conf->{ip_address}) {
369         $info->{ip_address} = $conf->{ip_address}->{value};
370     } else {
371         die "implement me";
372     }
373     return $info;
374 }
375
376 sub extract_vm_info {
377     my ($vmdata, $vmid) = @_;
378
379     my $info = { type => 'vm', vmid => $vmid };
380
381     my $conf = $vmdata->{qemu}->{$vmid} || die "no such VM '$vmid'";
382     my $net = PVE::QemuServer::parse_net($conf->{net0});
383     $info->{macaddr} = $net->{macaddr} || die "unable to get mac address";
384     $info->{bridge} = $net->{bridge} || die "unable to get bridge";
385     $info->{fwbr} = "fwbr${vmid}i0";
386     $info->{tapdev} = "tap${vmid}i0";
387     $info->{fwln} = "fwln${vmid}i0";
388     $info->{fwpr} = "fwpr${vmid}p0";
389
390     return $info;
391 }
392
393 sub simulate_firewall {
394     my ($ruleset, $ipset_ruleset, $vmdata, $test) = @_;
395
396     my $from = delete $test->{from} || die "missing 'from' field";
397     my $to = delete $test->{to} || die "missing 'to' field";
398     my $action = delete $test->{action} || die "missing 'action'";
399     
400     my $testid = delete $test->{id};
401     
402     die "from/to needs to be different" if $from eq $to;
403
404     my $pkg = {
405         proto => 'tcp',
406         sport => undef,
407         dport => undef,
408         source => undef,
409         dest => undef,
410     };
411
412     while (my ($k,$v) = each %$test) {
413         die "unknown attribute '$k'\n" if !exists($pkg->{$k});
414         $pkg->{$k} = $v;
415     }
416
417     my $from_info = {};
418
419     my $start_state;
420
421     if ($from eq 'host') {
422         $from_info->{type} = 'host';
423         $start_state = 'host';
424     } elsif ($from eq 'outside') {
425         $from_info->{type} = 'outside';
426         $start_state = 'from-outside';
427     } elsif ($from =~ m/^ct(\d+)$/) {
428         my $vmid = $1;
429         $from_info = extract_ct_info($vmdata, $vmid);
430         if ($from_info->{ip_address}) {
431             $pkg->{source} = $from_info->{ip_address};
432             $start_state = 'venet-out';
433         } else {
434             die "implement me";
435         }
436     } elsif ($from =~ m/^vm(\d+)$/) {
437         my $vmid = $1;
438         $from_info = extract_vm_info($vmdata, $vmid);
439         $start_state = 'fwbr-out'; 
440         $pkg->{mac_source} = $from_info->{macaddr};
441     } else {
442         die "implement me";
443     }
444
445     my $target;
446
447     if ($to eq 'host') {
448         $target->{type} = 'host';
449         $target->{iface} = 'host';
450     } elsif ($to eq 'outside') {
451         $target->{type} = 'outside';
452         $target->{iface} = $outside_iface;
453     } elsif ($to =~ m/^ct(\d+)$/) {
454         my $vmid = $1;
455         $target = extract_ct_info($vmdata, $vmid);
456         $target->{iface} = 'venet-in';
457
458         if ($target->{ip_address}) {
459             $pkg->{dest} = $target->{ip_address};
460         } else {
461             die "implement me";
462         }
463    } elsif ($to =~ m/^vm(\d+)$/) {
464         my $vmid = $1;
465         $target = extract_vm_info($vmdata, $vmid);
466         $target->{iface} = $target->{tapdev};
467     } else {
468         die "implement me";
469     }
470
471     my ($res, $ic, $rc) = route_packet($ruleset, $ipset_ruleset, $pkg, 
472                                        $from_info, $target, $start_state);
473
474     add_trace("IPT statistics: invocation = $ic, checks = $rc\n");
475  
476     die "test failed ($res != $action)\n" if $action ne $res;
477
478     return undef; 
479 }
480
481 sub run_tests {
482     my ($vmdata, $testdir, $testfile, $testid) = @_;
483
484     $testfile = 'tests' if !$testfile;
485
486     $vmdata->{testdir} = $testdir;
487
488     my ($ruleset, $ipset_ruleset) = 
489         PVE::Firewall::compile(undef, undef, $vmdata);
490
491     my $filename = "$testdir/$testfile";
492     my $fh = IO::File->new($filename) ||
493         die "unable to open '$filename' - $!\n";
494
495     my $testcount = 0;
496     while (defined(my $line = <$fh>)) {
497         next if $line =~ m/^\s*$/;
498         next if $line =~ m/^#.*$/;
499         if ($line =~ m/^\{.*\}\s*$/) {
500             my $test = eval $line;
501             die $@ if $@;
502             next if defined($testid) && (!defined($test->{id}) || ($testid ne $test->{id}));
503             $trace = '';
504             print Dumper($ruleset) if $debug;
505             $testcount++;
506             eval { simulate_firewall($ruleset, $ipset_ruleset, $vmdata, $test); };
507             if (my $err = $@) {
508
509                 print Dumper($ruleset) if !$debug;
510
511                 print "$trace\n" if !$debug;
512
513                 print "$filename line $.: $line";
514
515                 print "test failed: $err\n";
516
517                 exit(-1);
518             }
519         } else {
520             die "parse error";
521         }
522     }
523
524     die "no tests found\n" if $testcount <= 0;
525
526     print "PASS: $filename\n";
527
528     return undef;
529 }
530
531 my $vmdata = {
532     qemu => {
533         100 => {
534             net0 => "e1000=0E:0B:38:B8:B3:21,bridge=vmbr0",
535         },
536         101 => {
537             net0 => "e1000=0E:0B:38:B8:B3:22,bridge=vmbr0",
538         },
539         # on bridge vmbr1
540         110 => {
541             net0 => "e1000=0E:0B:38:B8:B4:21,bridge=vmbr1",
542         },
543     },
544     openvz => {
545         200 => {
546             ip_address => { value => '10.0.200.1' },
547         },
548         201 => {
549             ip_address => { value => '10.0.200.2' },
550         },
551     },
552 };
553
554 if ($testfilename) {
555     my $testfile;
556     my $dir;
557
558     if (-d $testfilename) {
559         $dir = $testfilename;
560     } elsif (-f $testfilename) {
561         $dir = dirname($testfilename);
562         $testfile = basename($testfilename);
563     } else {
564         die "no such file/dir '$testfilename'\n"; 
565     }
566
567     run_tests($vmdata, $dir, $testfile, $testid);
568
569 } else { 
570     foreach my $dir (<test-*>) {
571         next if ! -d $dir;
572         run_tests($vmdata, $dir);
573     }
574 }
575
576 print "OK - all tests passed\n";
577
578 exit(0);