Files
webmin/t/web-lib-funcs-open-socket-vm.t

272 lines
10 KiB
Perl

#!/usr/bin/perl
use strict;
use warnings;
no warnings qw(once redefine);
use Test::More;
use File::Temp qw(tempdir);
use FindBin;
use JSON::PP qw(decode_json);
use Symbol qw(gensym);
use POSIX qw(WNOHANG);
plan skip_all => 'Set WEBMIN_OPEN_SOCKET_VM_TEST=1 on a disposable Linux VM'
unless ($ENV{'WEBMIN_OPEN_SOCKET_VM_TEST'} || '') eq '1';
$^O eq 'linux' && $< == 0 or die 'Disposable root Linux VM required';
my $source = $ENV{'WEBMIN_OPEN_SOCKET_SOURCE'} ||
"$FindBin::Bin/../web-lib-funcs.pl";
my $task = tempdir('socket-compat-XXXXXX', DIR => '/tmp', CLEANUP => 1);
note("Fixture directory: $task");
my $server_pid;
END {
if ($server_pid) {
kill('TERM', $server_pid);
waitpid($server_pid, 0);
}
}
$server_pid = fork();
defined($server_pid) or die $!;
if (!$server_pid) {
open(STDOUT, '>', "$task/server.log") or die $!;
open(STDERR, '>&', STDOUT) or die $!;
exec('python3', "$FindBin::Bin/fixtures/open-socket-server.py", $task);
die $!;
}
for (1..100) {
last if -s "$task/ports.json";
my $exited = waitpid($server_pid, WNOHANG);
if ($exited == $server_pid) {
$server_pid = undef;
die "Fixture server exited: see $task/server.log";
}
select(undef, undef, undef, 0.1);
}
open(my $ports_file, '<', "$task/ports.json") or die $!;
my $ports = decode_json(do { local $/; <$ports_file> });
close($ports_file);
$ENV{'WEBMIN_CONFIG'} = '/etc/webmin';
$ENV{'WEBMIN_VAR'} = '/var/webmin';
open(my $mc, '<', '/etc/webmin/miniserv.conf') or die $!;
my ($root) = map { /^root=(.*)/ ? $1 : () } <$mc>;
close($mc);
chdir("$root/webmin") or die $!;
$0 = "$root/webmin/socket-compat-test.pl";
unshift(@INC, $root);
require WebminCore;
WebminCore->import();
init_config();
$main::error_must_die = 1;
# Substitute the changed staged functions while using installed Webmin callers.
open(my $source_file, '<', $source) or die $!;
my $code = do { local $/; <$source_file> };
close($source_file);
for my $function (qw(http_download complete_http_download open_socket
make_http_connection)) {
$code =~ /\n(sub \Q$function\E\n\{.*?\n\})\n\n=head2 /s or
die "Cannot extract $function";
my $sub = $1;
eval "package WebminCore; no strict; no warnings 'redefine'; $sub";
die $@ if $@;
no strict 'refs';
*{"main::$function"} = \&{"WebminCore::$function"};
}
# Limit fixture DNS to loopback while preserving the real literal-IP handling.
my $original4 = \&WebminCore::to_ipaddress;
my $original6 = \&WebminCore::to_ip6address;
my %addresses = (
'four.invalid' => [['127.0.0.1'], []],
'six.invalid' => [[], ['::1']],
'dual.invalid' => [['127.0.0.1'], ['::1']],
'multiple.invalid' => [['127.0.0.2', '127.0.0.1'], ['::1']],
'missing.invalid' => [[], []],
);
*WebminCore::to_ipaddress = sub {
return $original4->(@_) unless exists $addresses{$_[0]};
my @result = @{$addresses{$_[0]}->[0]};
return wantarray ? @result : $result[0];
};
*WebminCore::to_ip6address = sub {
return $original6->(@_) unless exists $addresses{$_[0]};
my @result = @{$addresses{$_[0]}->[1]};
return wantarray ? @result : $result[0];
};
# No configured proxy credentials or unrelated HTTP cache writes enter the fixtures.
local @WebminCore::gconfig{qw(http_proxy ftp_proxy proxy_user proxy_pass proxy_fallback bind_proxy)};
local *WebminCore::write_to_http_cache = sub {};
local *WebminCore::no_proxy = sub { 0 };
sub download
{
my ($host, $port, $ssl, $post, $family) = @_;
my ($body, $error);
if (defined $post) {
http_post($host, $port, '/marker', $post, \$body, \$error,
undef, $ssl, undef, undef, 5, 0, 1);
}
else {
http_download($host, $port, '/marker', \$body, \$error,
undef, $ssl, undef, undef, 5, 0, 1, undef, undef, $family);
}
is($error, undef, 'request succeeds');
my $result = eval { decode_json($body || '') };
ok($result, 'server returned the fixture response') or diag($body || '(empty)');
return $result || {};
}
for my $case (
['HTTP IPv4 literal', '127.0.0.1', 'http4', 0],
['HTTP IPv6 literal', '::1', 'http6', 0],
['HTTP IPv4 hostname', 'four.invalid', 'http4', 0],
['HTTP IPv6-only hostname', 'six.invalid', 'http6', 0],
['HTTP dual-stack IPv4 preference', 'dual.invalid', 'http4', 0],
['HTTP second IPv4 address', 'multiple.invalid', 'http4', 0],
['HTTP IPv6 fallback', 'dual.invalid', 'http6', 0],
['HTTPS IPv4', 'four.invalid', 'https4', 1],
['HTTPS IPv6', 'six.invalid', 'https6', 1],
['HTTPS IPv6 fallback', 'dual.invalid', 'https6', 1],
) {
subtest $case->[0] => sub {
my (undef, $host, $port, $ssl) = @$case;
my $reply = download($host, $ports->{$port}, $ssl);
is($reply->{'host'}, $host, 'HTTP Host is unchanged');
is($reply->{'request'}, 'GET /marker HTTP/1.0', 'request reaches the endpoint');
is($reply->{'sni'}, $host, 'TLS SNI is unchanged') if $ssl;
};
}
for my $ssl (0, 1) {
my $protocol = $ssl ? 'HTTPS' : 'HTTP';
my $port_prefix = lc($protocol);
for my $family (4, 6) {
subtest "$protocol forced IPv$family" => sub {
my $reply = download('dual.invalid',
$ports->{$port_prefix.$family}, $ssl, undef, $family);
is($reply->{'host'}, 'dual.invalid',
'request reaches the matching address family');
};
}
for my $case ([4, 6], [6, 4]) {
my ($family, $listener_family) = @$case;
subtest "$protocol forced IPv$family skips IPv$listener_family" => sub {
my ($body, $error);
http_download('dual.invalid',
$ports->{$port_prefix.$listener_family}, '/', \$body,
\$error, undef, $ssl, undef, undef, 5, 0, 1, undef,
undef, $family);
like($error, qr/^Failed to (?:IPv6 )?connect to dual\.invalid:/,
'request does not fall back to the other address family');
};
}
}
subtest 'POST request body' => sub {
my $reply = download('four.invalid', $ports->{'https4'}, 1, 'fixture=marker');
is($reply->{'posted'}, 'fixture=marker', 'HTTPS POST body is preserved');
};
for my $family (4, 6) {
for my $ssl (0, 1) {
subtest "HTTP proxy over IPv$family with TLS=$ssl" => sub {
local $WebminCore::gconfig{'http_proxy'} = "http://dual.invalid:$ports->{'proxy'.$family}";
my $reply = download('origin.invalid', 8443, $ssl);
is($reply->{'host'}, 'origin.invalid', 'origin HTTP Host is preserved');
is($reply->{'request'}, $ssl ? 'GET /marker HTTP/1.0' :
'GET http://origin.invalid:8443/marker HTTP/1.0', 'proxy request format is preserved');
if ($ssl) {
is($reply->{'tunnel'}, 'CONNECT origin.invalid:8443 HTTP/1.0', 'CONNECT negotiation succeeds');
is($reply->{'sni'}, 'origin.invalid', 'TLS uses the origin hostname');
}
};
}
}
subtest 'HTTP proxy failure with direct fallback' => sub {
local $WebminCore::gconfig{'http_proxy'} = "http://four.invalid:$ports->{'refused'}";
local $WebminCore::gconfig{'proxy_fallback'} = 1;
my $reply = download('four.invalid', $ports->{'http4'}, 0);
is($reply->{'request'}, 'GET /marker HTTP/1.0', 'falls back to a direct request');
};
subtest 'HTTP proxy failure without fallback' => sub {
local $WebminCore::gconfig{'http_proxy'} = "http://four.invalid:$ports->{'refused'}";
my ($body, $error);
http_download('four.invalid', $ports->{'http4'}, '/', \$body, \$error,
undef, 0, undef, undef, 5, 0, 1);
like($error, qr/^Failed to connect to four\.invalid:/, 'proxy connection failure reaches the caller');
};
subtest 'Configured outgoing IPv4 address' => sub {
local $WebminCore::gconfig{'bind_proxy'} = '127.0.0.2';
my $reply = download('four.invalid', $ports->{'http4'}, 0);
is($reply->{'peer'}, '127.0.0.2', 'configured source address is used');
};
subtest 'Explicit outgoing IPv4 address' => sub {
local $WebminCore::gconfig{'bind_proxy'} = '127.0.0.2';
my $handle = make_http_connection('four.invalid', $ports->{'http4'}, 0,
'GET', '/marker', [['Host', 'four.invalid']], '127.0.0.3');
ok(ref($handle), 'connection succeeds with explicit bind address');
my ($body, $error);
complete_http_download($handle, \$body, \$error, undef, undef,
'four.invalid', $ports->{'http4'}, undef, 0, 1, 5);
is($error, undef, 'response succeeds');
is(decode_json($body)->{'peer'}, '127.0.0.3', 'explicit source address takes precedence');
};
for my $case (
['DNS failure', 'missing.invalid', qr/Failed to lookup IP address/],
['Connection refusal', 'four.invalid', qr/Failed to connect to four\.invalid:/],
) {
subtest $case->[0] => sub {
my $fh = gensym();
my $error;
is(open_socket($case->[1], $ports->{'refused'}, $fh, \$error), undef, 'returns failure');
like($error, $case->[2], 'returns the expected error');
close($fh) if defined(fileno($fh));
};
}
subtest 'Unavailable source address' => sub {
local $WebminCore::gconfig{'bind_proxy'} = '192.0.2.123';
my $fh = gensym();
my $error;
is(open_socket('four.invalid', $ports->{'http4'}, $fh, \$error), undef, 'bind failure is reported');
like($error, qr/^Failed to bind to source address :/, 'bind error is preserved');
close($fh) if defined(fileno($fh));
};
subtest 'Error without a reference' => sub {
my $fh = gensym();
eval { open_socket('missing.invalid', 443, $fh) };
like($@, qr/Failed to lookup IP address/, 'throws for callers without an error reference');
};
{
package FixtureCaller;
sub greeting {
my ($port) = @_;
my $error;
WebminCore::open_socket('127.0.0.1', $port, 'GREETING', \$error);
die $error if $error;
my $line = <GREETING>;
close(GREETING);
return $line;
}
}
is(FixtureCaller::greeting($ports->{'greeting'}), "220 loopback fixture ready\r\n",
'a named handle in another package can read a service greeting');
for my $family (4, 6) {
subtest "Passive FTP over IPv$family" => sub {
my $file = "$task/ftp-$family.txt";
my $error;
my $host = $family == 4 ? 'four.invalid' : 'six.invalid';
my $result = ftp_download($host, '/marker.txt', $file, \$error, undef,
'fixture', 'fixture', $ports->{'ftp'.$family}, 1, 5);
is($error, undef, 'FTP download has no error');
ok($result, 'FTP reports success');
open(my $fh, '<', $file) or die $!;
is(do { local $/; <$fh> }, "passive FTP marker\n", 'control and data connections work');
close($fh);
};
}
done_testing();