Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
lima-vm
GitHub Repository: lima-vm/lima
Path: blob/master/hack/test-port-forwarding.pl
2601 views
1
#!/usr/bin/env perl
2
3
# This script tests the port forwarding settings of lima. It has to be run
4
# twice: once to update the instance yaml file with the port forwarding
5
# rules (before the instance is started). And once when the instance is
6
# running to perform the tests:
7
#
8
# ./hack/test-port-forwarding.pl templates/default.yaml
9
# limactl --tty=false start templates/default.yaml
10
# git restore templates/default.yaml
11
# ./hack/test-port-forwarding.pl default [nc|socat [nc|socat]] [timeout]
12
#
13
# TODO: support for ipv6 host addresses
14
15
use strict;
16
use warnings;
17
18
use Config qw(%Config);
19
use File::Spec::Functions qw(catfile);
20
use IO::Handle qw();
21
use JSON::PP;
22
use Socket qw(inet_ntoa);
23
use Sys::Hostname qw(hostname);
24
25
my $connectionTimeout = 1; # seconds
26
27
my $instance = shift;
28
my $listener;
29
my $writer;
30
while (my $arg = shift) {
31
if ($arg eq "nc" || $arg eq "socat") {
32
$listener = $arg unless defined $listener;
33
$writer = $arg if defined $listener && !defined $writer;
34
} elsif ($arg =~ /^\d+$/) {
35
$connectionTimeout = $arg;
36
} else {
37
die "Usage: $0 [instance|yaml-file] [nc|socat [nc|socat]] [timeout]\n";
38
}
39
}
40
$listener ||= "nc";
41
$writer ||= $listener;
42
43
my $addr = scalar gethostbyname(hostname());
44
# If hostname address cannot be determines, use localhost to trigger fallback to system_profiler lookup
45
my $ipv4 = length $addr ? inet_ntoa($addr) : "127.0.0.1";
46
my $ipv6 = ""; # todo
47
48
# macOS GitHub runners seem to use "localhost" as the hostname
49
if ($ipv4 eq "127.0.0.1" && $Config{osname} eq "darwin") {
50
$ipv4 = qx(system_profiler SPNetworkDataType -json | jq -r 'first(.SPNetworkDataType[] | select(.ip_address) | .ip_address) | first');
51
chomp $ipv4;
52
}
53
54
my $instDir = qx(limactl list "$instance" --yq .dir);
55
chomp $instDir;
56
# platform independent way to add trailing path separator
57
my $sockDir = catfile($instDir, "sock", "");
58
59
# If $instance is a filename, add our portForwards to it to enable testing
60
if (-f $instance) {
61
open(my $fh, "+< $instance") or die "Can't open $instance for read/write: $!";
62
my @yaml;
63
while (<$fh>) {
64
# Remove existing "portForwards:" section from the config file
65
my $seq = /^portForwards:/ ... /^[a-z]/;
66
next if $seq && $seq !~ /E0$/;
67
push @yaml, $_;
68
}
69
seek($fh, 0, 0);
70
truncate($fh, 0);
71
print $fh $_ for @yaml;
72
while (<DATA>) {
73
s/ipv4/$ipv4/g;
74
s/ipv6/$ipv6/g;
75
print $fh $_;
76
}
77
exit;
78
}
79
80
# Check if netcat is available before running tests
81
my $nc_path = `command -v nc 2>/dev/null`;
82
chomp $nc_path;
83
unless ($nc_path) {
84
die "Error: 'nc' (netcat) is not installed on the host system.\n" .
85
"Please install netcat to run this test script:\n" .
86
" - On macOS: brew install netcat\n" .
87
" - On Ubuntu/Debian: sudo apt-get install netcat\n" .
88
" - On RHEL/CentOS: sudo yum install nmap-ncat\n";
89
}
90
91
# Otherwise $instance must be the name of an already running instance that has been
92
# configured with our portForwards settings.
93
94
my $instanceType = qx(limactl ls --json "$instance" | jq -r '.vmType' | sed s/x/x/);
95
chomp $instanceType;
96
97
# Get sshLocalPort for lima instance
98
my $sshLocalPort;
99
open(my $ls, "limactl ls --json |") or die;
100
while (<$ls>) {
101
next unless /"name":"$instance"/;
102
($sshLocalPort) = /"sshLocalPort":(\d+)/ or die;
103
last;
104
}
105
die "Cannot determine sshLocalPort" unless $sshLocalPort;
106
107
# Extract forwarding tests from the "portForwards" section
108
my @test;
109
while (<DATA>) {
110
chomp;
111
s/^\s+#\s*//;
112
next unless /^(forward|ignore)/;
113
if (/ipv6/ && !$ipv6) {
114
printf " Not yet: # $_\n";
115
next;
116
}
117
s/sshLocalPort/$sshLocalPort/g;
118
s/ipv4/$ipv4/g;
119
s/ipv6/$ipv6/g;
120
s/sockDir\//$sockDir/g;
121
# forward: 127.0.0.1 899 → 127.0.0.1 799
122
# ignore: 127.0.0.2 8888
123
/^(forward|ignore):\s+([0-9.:]+)\s+(\d+)(?:\s+)?(?:\s+(?:([0-9.:]+)(?:\s+(\d+))|(\S+))?)?/;
124
die "Cannot parse test '$_'" unless $1;
125
my %test; @test{qw(mode guest_ip guest_port host_ip host_port host_socket)} = ($1, $2, $3, $4, $5, $6);
126
127
$test{host_ip} ||= "127.0.0.1";
128
$test{host_port} ||= $test{guest_port};
129
$test{host_socket} ||= "";
130
if ($test{mode} eq "forward" && $test{host_socket} eq "" && $test{host_port} < 1024 && $Config{osname} ne "darwin") {
131
printf "🚧 Not supported on $Config{osname}: # $_\n";
132
next;
133
}
134
if ($test{mode} eq "ignore" && ($test{guest_ip} eq "0.0.0.0" || $test{guest_ip} eq "127.0.0.1") && "$instanceType" eq "wsl2") {
135
printf "🚧 Not supported for $instanceType machines: # $_\n";
136
next;
137
}
138
if ($test{guest_ip} eq "192.168.5.15" && "$instanceType" eq "wsl2") {
139
printf "🚧 Not supported for $instanceType machines: # $_\n";
140
next;
141
}
142
if ($test{host_socket} ne "" && $Config{osname} eq "cygwin") {
143
printf "🚧 Not supported on $Config{osname}: # $_\n";
144
next;
145
}
146
147
my $remote = JoinHostPort($test{guest_ip},$test{guest_port});
148
my $local = $test{host_socket} eq "" ? JoinHostPort($test{host_ip},$test{host_port}) : $test{host_socket};
149
if ($test{mode} eq "ignore") {
150
$test{log_msg} = "Not forwarding TCP $remote";
151
}
152
else {
153
$test{log_msg} = "Forwarding TCP from $remote to $local";
154
}
155
push @test, \%test;
156
}
157
158
open(my $lima, "| limactl shell --workdir / $instance")
159
or die "Can't run lima shell on $instance: $!";
160
$lima->autoflush;
161
162
print $lima <<'EOF';
163
set -e
164
cd $HOME
165
sudo pkill -x nc || true
166
sudo pkill -x socat || true
167
rm -f nc.* socat.*
168
EOF
169
170
# Give the hostagent some time to remove any port forwards from a previous (crashed?) test run
171
sleep 5;
172
173
# Record current log size, so we can skip prior output
174
$ENV{HOME_HOST} ||= "$ENV{HOME}";
175
$ENV{LIMA_HOME} ||= "$ENV{HOME_HOST}/.lima";
176
my $ha_stdout_log = "$ENV{LIMA_HOME}/$instance/ha.stdout.log";
177
my $ha_stderr_log = "$ENV{LIMA_HOME}/$instance/ha.stderr.log";
178
my $ha_stdout_log_size = -s $ha_stdout_log or die;
179
my $ha_stderr_log_size = -s $ha_stderr_log or die;
180
181
# Setup a netcat listener on the guest for each test
182
foreach my $id (0..@test-1) {
183
my $test = $test[$id];
184
my $cmd;
185
if ($listener eq "nc") {
186
$cmd = "nc -l $test->{guest_ip} $test->{guest_port}";
187
if ($instance =~ /^alpine/) {
188
$cmd = "nc -l -s $test->{guest_ip} -p $test->{guest_port}";
189
}
190
} elsif ($listener eq "socat") {
191
my $proto = $test->{guest_ip} =~ /:/ ? "TCP6" : "TCP";
192
$cmd = "socat -u $proto-LISTEN:$test->{guest_port},bind=$test->{guest_ip} STDOUT";
193
}
194
195
my $sudo = $test->{guest_port} < 1024 ? "sudo " : "";
196
print $lima "${sudo}${cmd} >$listener.${id} 2>/dev/null &\n";
197
}
198
199
# Make sure the guest- and hostagents had enough time to set up the forwards
200
sleep 5;
201
202
# Try to reach each listener from the host
203
foreach my $test (@test) {
204
next if $test->{host_port} == $sshLocalPort;
205
my $cmd;
206
if ($writer eq "nc") {
207
if ($Config{osname} eq "darwin") {
208
# macOS nc doesn't support -w for connection timeout, so use -G instead
209
$cmd = $test->{host_socket} eq "" ? "nc -G $connectionTimeout $test->{host_ip} $test->{host_port}" : "nc -G $connectionTimeout -U $test->{host_socket}";
210
} else {
211
$cmd = $test->{host_socket} eq "" ? "nc -w $connectionTimeout $test->{host_ip} $test->{host_port}" : "nc -w $connectionTimeout -U $test->{host_socket}";
212
}
213
} elsif ($writer eq "socat") {
214
my $tcp_dest = $test->{host_ip} =~ /:/ ? "TCP6:[$test->{host_ip}]:$test->{host_port}" : "TCP:$test->{host_ip}:$test->{host_port}";
215
$cmd = $test->{host_socket} eq "" ? "socat -u STDIN $tcp_dest,connect-timeout=$connectionTimeout" : "socat -u STDIN UNIX-CONNECT:$test->{host_socket}";
216
}
217
print "Running: $cmd\n";
218
open(my $netcat, "| $cmd") or die "Can't run '$cmd': $!";
219
print $netcat "$test->{log_msg}\n";
220
# Don't check for errors on close; macOS nc seems to return non-zero exit code even on success
221
close($netcat);
222
}
223
224
# Extract forwarding log messages from hostagent JSON event log
225
my $json_parser = JSON::PP->new->utf8->relaxed;
226
227
open(my $log, "< $ha_stdout_log") or die "Can't read $ha_stdout_log: $!";
228
seek($log, $ha_stdout_log_size, 0) or die "Can't seek $ha_stdout_log to $ha_stdout_log_size: $!";
229
my %seen;
230
my %failed_to_listen_tcp;
231
232
while (<$log>) {
233
chomp;
234
next unless /^\s*\{/; # Skip non-JSON lines
235
236
my $event = eval { $json_parser->decode($_) };
237
next unless $event;
238
239
my $pf = $event->{status}{portForward};
240
next unless $pf && $pf->{type};
241
242
my $type = $pf->{type};
243
my $protocol = uc($pf->{protocol} || "tcp");
244
my $guest_addr = $pf->{guestAddr} || "";
245
my $host_addr = $pf->{hostAddr} || "";
246
my $error = $pf->{error} || "";
247
248
if ($type eq "forwarding") {
249
my $msg = "Forwarding $protocol from $guest_addr to $host_addr";
250
$seen{$msg}++;
251
} elsif ($type eq "not-forwarding") {
252
my $msg = "Not forwarding $protocol $guest_addr";
253
$seen{$msg}++;
254
} elsif ($type eq "failed" && $error =~ /listen tcp/) {
255
# Extract the address from the error message
256
if ($error =~ /listen tcp (.*?:\d+):/) {
257
my $addr = $1;
258
$failed_to_listen_tcp{$addr} = "failed to listen tcp: $error";
259
}
260
}
261
}
262
close $log or die;
263
264
# Also check stderr log for failed_to_listen_tcp messages (these may not be in JSON events)
265
open(my $stderr_log, "< $ha_stderr_log") or die "Can't read $ha_stderr_log: $!";
266
seek($stderr_log, $ha_stderr_log_size, 0) or die "Can't seek $ha_stderr_log to $ha_stderr_log_size: $!";
267
while (<$stderr_log>) {
268
$failed_to_listen_tcp{$2}=$1 if /(failed to listen tcp: listen tcp (.*?:\d+):[^"]+)/;
269
}
270
close $stderr_log or die;
271
272
my $rc = 0;
273
my %expected;
274
foreach my $id (0..@test-1) {
275
my $test = $test[$id];
276
my $err = "";
277
$expected{$test->{log_msg}}++;
278
unless ($seen{$test->{log_msg}}) {
279
$err .= "\n Message missing from ha.stdout.log (JSON events)";
280
}
281
my $log = qx(limactl shell --workdir / $instance sh -c "cd; cat $listener.$id");
282
chomp $log;
283
if ($test->{mode} eq "forward" && $test->{log_msg} ne $log) {
284
$err .= "\n Guest received: '$log'";
285
}
286
if ($test->{mode} eq "ignore" && $log) {
287
$err .= "\n Guest received: '$log' (instead of nothing)";
288
}
289
printf "%s %s%s\n", ($err ? "" : ""), $test->{log_msg}, $err;
290
$rc = 1 if $err;
291
}
292
293
foreach (keys %seen) {
294
next if $expected{$_};
295
# Should this be an error? Really should only happen if something else failed as well.
296
print " Unexpected log message: $_\n";
297
}
298
299
if (%failed_to_listen_tcp) {
300
foreach (keys %failed_to_listen_tcp) {
301
print " $failed_to_listen_tcp{$_}\n";
302
}
303
my @tcp_list = keys %failed_to_listen_tcp;
304
if ($Config{osname} eq "darwin") {
305
my @lsof_args = map { "-iTCP\@$_" } @tcp_list;
306
print `lsof -P @lsof_args`;
307
} elsif ($Config{osname} eq "linux") {
308
my @lss_args = map { "src = $_" } @tcp_list;
309
my $ss_expression = join(" or ", @lss_args);
310
print `sudo ss -lnpt "$ss_expression"`;
311
} elsif ($Config{osname} eq "cygwin") {
312
my @awk_args = map { "-e'/$_/'" } @tcp_list;
313
print `netstat -aon | awk -e'/^ +Proto/' @awk_args`;
314
}
315
}
316
317
# Cleanup remaining netcat instances (and port forwards)
318
print $lima "sudo pkill -x $listener";
319
320
exit $rc;
321
322
sub JoinHostPort {
323
my($host,$port) = @_;
324
$host = "[$host]" if $host =~ /:/;
325
return "$host:$port";
326
}
327
328
# This YAML section includes port forwarding `rules` for the guest- and hostagents,
329
# with interleaved `tests` (in comments) that are executed by this script. The strings
330
# "ipv4" and "ipv6" will be replaced by the actual host ipv4 and ipv6 addresses.
331
__DATA__
332
portForwards:
333
# We can't test that port 22 will be blocked because the guestagent has
334
# been ignoring it since startup, so the log message is in the part of
335
# the log we skipped.
336
# skip: 127.0.0.1 22 → 127.0.0.1 2222
337
# ignore: 127.0.0.1 sshLocalPort
338
339
- guestIP: 127.0.0.2
340
guestPortRange: [3000, 3009]
341
hostPortRange: [2000, 2009]
342
ignore: true
343
344
- guestIP: 0.0.0.0
345
guestIPMustBeZero: false
346
guestPortRange: [3010, 3019]
347
hostPortRange: [2010, 2019]
348
ignore: true
349
350
- guestIP: 0.0.0.0
351
guestIPMustBeZero: false
352
guestPortRange: [3000, 3029]
353
hostPortRange: [2000, 2029]
354
355
# The following rule is completely shadowed by the previous one and has no effect
356
- guestIP: 0.0.0.0
357
guestIPMustBeZero: false
358
guestPortRange: [3020, 3029]
359
hostPortRange: [2020, 2029]
360
ignore: true
361
362
# ignore: 127.0.0.2 3000
363
# forward: 127.0.0.3 3001 → 127.0.0.1 2001
364
365
# Blocking 127.0.0.2 cannot block forwarding from 0.0.0.0
366
# forward: 0.0.0.0 3002 → 127.0.0.1 2002
367
368
# Blocking 0.0.0.0 will block forwarding from any interface because guestIPMustBeZero is false
369
# ignore: 0.0.0.0 3010
370
# ignore: 127.0.0.1 3011
371
372
# Forwarding from 0.0.0.0 works for any interface (including IPv6)
373
# The "ignore" rule above has no effect because the previous rule already matched.
374
# forward: 127.0.0.2 3020 → 127.0.0.1 2020
375
# forward: 127.0.0.1 3021 → 127.0.0.1 2021
376
# forward: 0.0.0.0 3022 → 127.0.0.1 2022
377
# forward: :: 3023 → 127.0.0.1 2023
378
# forward: ::1 3024 → 127.0.0.1 2024
379
380
- guestPortRange: [3030, 3039]
381
hostPortRange: [2030, 2039]
382
hostIP: ipv4
383
384
# forward: 127.0.0.1 3030 → ipv4 2030
385
# forward: 0.0.0.0 3031 → ipv4 2031
386
# forward: :: 3032 → ipv4 2032
387
# forward: ::1 3033 → ipv4 2033
388
389
- guestPortRange: [300, 304]
390
391
# forward: 127.0.0.1 300 → 127.0.0.1 300
392
# forward: 0.0.0.0 301 → 127.0.0.1 301
393
# forward: :: 302 → 127.0.0.1 302
394
# forward: ::1 303 → 127.0.0.1 303
395
# ignore: 192.168.5.15 304 → 127.0.0.1 304
396
397
- guestPortRange: [305, 309]
398
guestIPMustBeZero: false
399
400
# forward: 127.0.0.1 325 → 127.0.0.1 325
401
# forward: 0.0.0.0 326 → 127.0.0.1 326
402
# forward: :: 327 → 127.0.0.1 327
403
# forward: ::1 328 → 127.0.0.1 328
404
# ignore: 192.168.5.15 329 → 127.0.0.1 329
405
406
- guestPortRange: [310, 314]
407
hostIP: 0.0.0.0
408
409
# forward: 127.0.0.1 310 → 0.0.0.0 310
410
# forward: 0.0.0.0 311 → 0.0.0.0 311
411
# forward: :: 312 → 0.0.0.0 312
412
# forward: ::1 313 → 0.0.0.0 313
413
# ignore: 192.168.5.15 314 → 0.0.0.0 314
414
415
- guestPortRange: [315, 319]
416
guestIPMustBeZero: false
417
hostIP: 0.0.0.0
418
419
# forward: 127.0.0.1 315 → 0.0.0.0 315
420
# forward: 0.0.0.0 316 → 0.0.0.0 316
421
# forward: :: 317 → 0.0.0.0 317
422
# forward: ::1 318 → 0.0.0.0 318
423
# ignore: 192.168.5.15 319 → 0.0.0.0 319
424
425
# Things we can't test:
426
# - Accessing a forward from a different interface (e.g. connect to ipv4 to connect to 0.0.0.0)
427
# - failed forward to privileged port
428
429
430
- guestIP: "192.168.5.15"
431
guestPortRange: [4000, 4009]
432
hostIP: "ipv4"
433
434
# forward: 192.168.5.15 4000 → ipv4 4000
435
436
- guestIP: "::1"
437
guestPortRange: [4010, 4019]
438
hostIP: "::"
439
440
# forward: ::1 4010 → :: 4010
441
442
- guestIP: "::"
443
guestPortRange: [4020, 4029]
444
hostIP: "ipv4"
445
446
# forward: 127.0.0.1 4020 → ipv4 4020
447
# forward: 127.0.0.2 4021 → ipv4 4021
448
# forward: 192.168.5.15 4022 → ipv4 4022
449
# forward: 0.0.0.0 4023 → ipv4 4023
450
# forward: :: 4024 → ipv4 4024
451
# forward: ::1 4025 → ipv4 4025
452
453
- guestIP: "0.0.0.0"
454
guestIPMustBeZero: false
455
guestPortRange: [4030, 4039]
456
hostIP: "ipv4"
457
458
# forward: 127.0.0.1 4030 → ipv4 4030
459
# forward: 127.0.0.2 4031 → ipv4 4031
460
# forward: 192.168.5.15 4032 → ipv4 4032
461
# forward: 0.0.0.0 4033 → ipv4 4033
462
# forward: :: 4034 → ipv4 4034
463
# forward: ::1 4035 → ipv4 4035
464
465
- guestIPMustBeZero: true
466
guestPortRange: [4040, 4049]
467
468
- guestIP: "0.0.0.0"
469
guestIPMustBeZero: false
470
guestPortRange: [4040, 4049]
471
ignore: true
472
473
# forward: 0.0.0.0 4040 → 127.0.0.1 4040
474
# forward: :: 4041 → 127.0.0.1 4041
475
# ignore: 127.0.0.1 4043 → 127.0.0.1 4043
476
# ignore: 192.168.5.15 4044 → 127.0.0.1 4044
477
478
# This rule exist to test `nerdctl run` binding to 0.0.0.0 by default,
479
# and making sure it gets forwarded to the external host IP.
480
# The actual test code is in test-example.sh in the "port-forwarding" block.
481
- guestIPMustBeZero: true
482
guestPort: 8888
483
hostIP: 0.0.0.0
484
485
- guestPort: 5000
486
hostSocket: port5000.sock
487
488
# forward: 127.0.0.1 5000 → sockDir/port5000.sock
489
490
- guestPort: 5001
491
hostSocket: port5001.sock
492
493
# ignore: 192.168.5.15 5001 → sockDir/port5001.sock
494
495
- guestPort: 5002
496
guestIPMustBeZero: false
497
hostSocket: port5002.sock
498
499
# forward: 127.0.0.1 5002 → sockDir/port5002.sock
500
501
- guestPort: 5003
502
guestIPMustBeZero: false
503
hostSocket: port5003.sock
504
505
# ignore: 192.168.5.15 5003 → sockDir/port5003.sock
506
507