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