]> git.proxmox.com Git - pve-firewall.git/blob - test/fwtester.pl
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);