#!/usr/bin/env perl
# Starts a private Apache that includes the xcat-ws.conf fragment from the
# source tree, once without mod_rewrite and once with it, and checks what the
# REST aliases and a provisioning path answer.
use strict;
use warnings;
use FindBin;
use File::Slurper qw(read_text);
use File::Spec;
use File::Temp qw(tempdir);
use IO::Socket::INET;
use POSIX qw(WNOHANG _exit setsid);
use Test::More;
use Time::HiRes qw(sleep);
my $fragment = $ENV{XCAT_WS_CONF}
|| File::Spec->rel2abs("$FindBin::Bin/../../xCAT-server/xCAT-wsapi/xcat-ws.conf");
plan skip_all => "the xcat-ws.conf fragment is not at $fragment"
unless -r $fragment;
my $httpd = find_httpd();
plan skip_all => 'no Apache httpd binary is installed' unless $httpd;
my $builtin = join( "\n", `$httpd -l 2>/dev/null` );
my $moddir = find_module_dir();
plan skip_all => 'no Apache module directory with mod_alias.so is present'
unless $moddir;
#-----------------------------------------------------------------------------
=head3 find_httpd
Descriptions: Finds the Apache binary of the distribution.
Arguments: none
Returns: the path, or undef when no binary is installed.
=cut
#-----------------------------------------------------------------------------
sub find_httpd {
foreach my $path (qw(/usr/sbin/httpd /usr/sbin/apache2 /usr/sbin/httpd-prefork)) {
return $path if -x $path;
}
return undef;
}
#-----------------------------------------------------------------------------
=head3 find_module_dir
Descriptions: Finds the directory that holds the Apache shared modules.
Arguments: none
Returns: the path, or undef when no candidate holds mod_alias.so.
=cut
#-----------------------------------------------------------------------------
sub find_module_dir {
foreach my $dir (
qw(/usr/lib64/httpd/modules /usr/lib/httpd/modules /usr/lib/apache2/modules
/usr/lib64/apache2-prefork /usr/lib64/apache2)
)
{
return $dir if -e "$dir/mod_alias.so";
}
return undef;
}
#-----------------------------------------------------------------------------
=head3 load_module_lines
Descriptions: Builds the LoadModule lines for the modules that are not
compiled into the binary.
Arguments: the module names without the mod_ prefix
Returns: the configuration text.
=cut
#-----------------------------------------------------------------------------
sub load_module_lines {
my (@names) = @_;
my $text = '';
foreach my $name (@names) {
next if $builtin =~ /\bmod_\Q$name\E\.c\b/;
die "mod_$name.so is not in $moddir" unless -e "$moddir/mod_$name.so";
$text .= "LoadModule ${name}_module $moddir/mod_$name.so\n";
}
return $text;
}
#-----------------------------------------------------------------------------
=head3 free_port
Descriptions: Asks the kernel for a free TCP port on the loopback address.
Arguments: none
Returns: the port number.
=cut
#-----------------------------------------------------------------------------
sub free_port {
my $sock = IO::Socket::INET->new(
LocalAddr => '127.0.0.1',
LocalPort => 0,
Listen => 1,
Proto => 'tcp',
) or die "Unable to find a free port: $!";
my $port = $sock->sockport();
close($sock);
return $port;
}
#-----------------------------------------------------------------------------
=head3 write_config
Descriptions: Writes an Apache configuration that serves a stub REST CGI
through the xCAT aliases and a provisioning file under
/install, then includes the fragment.
Arguments: the work directory, the port, true to load mod_rewrite
Returns: the path of the configuration file.
=cut
#-----------------------------------------------------------------------------
sub write_config {
my ( $dir, $port, $with_rewrite ) = @_;
my @modules = qw(mpm_prefork unixd authz_core alias cgi);
push @modules, 'rewrite' if $with_rewrite;
@modules = grep { $_ ne 'mpm_prefork' } @modules
if $builtin =~ /\b(prefork|worker|event)\.c\b/;
my $user = '';
if ( $> == 0 ) {
my $group = getgrgid( ( getpwnam('nobody') )[3] );
$user = "User nobody\nGroup $group\n";
}
# UseCanonicalName gives the fragment SERVER_PORT 80 on a private port.
my $conf = load_module_lines(@modules) . <<"EOF";
ServerRoot $dir
ServerName localhost:80
UseCanonicalName On
Listen 127.0.0.1:$port
PidFile $dir/httpd.pid
DefaultRuntimeDir $dir
ErrorLog $dir/error_log
LogLevel warn
$user
DocumentRoot $dir/htdocs
AllowOverride None
Require all denied
Require all granted
Alias /install $dir/install
Require all granted
ScriptAlias /xcatws $dir/ws/xcatws.cgi
ScriptAlias /xcatwsv2 $dir/ws/xcatws.cgi
Include $fragment
EOF
my $path = "$dir/httpd.conf";
open( my $fh, '>', $path ) or die "Unable to write $path: $!";
print $fh $conf;
close($fh) or die "Unable to close $path: $!";
return $path;
}
#-----------------------------------------------------------------------------
=head3 make_tree
Descriptions: Creates the document tree, the provisioning file and the
stub REST CGI under a new directory.
Arguments: none
Returns: the directory.
=cut
#-----------------------------------------------------------------------------
sub make_tree {
my $dir = tempdir( CLEANUP => 1 );
# Apache drops to nobody when the test runs as root.
chmod 0755, $dir or die "Unable to chmod $dir: $!";
foreach my $sub (qw(htdocs install ws)) {
mkdir "$dir/$sub" or die "Unable to create $dir/$sub: $!";
}
open( my $probe, '>', "$dir/install/probe.txt" ) or die "probe.txt: $!";
print $probe "install-probe\n";
close($probe);
my $cgi = "$dir/ws/xcatws.cgi";
open( my $fh, '>', $cgi ) or die "Unable to write $cgi: $!";
print $fh "#!/bin/sh\nprintf 'Content-Type: text/plain\\n\\nxcatws-stub\\n'\n";
close($fh);
chmod 0755, $cgi or die "Unable to chmod $cgi: $!";
return $dir;
}
#-----------------------------------------------------------------------------
=head3 start_httpd
Descriptions: Starts Apache in the foreground and waits for its port.
Arguments: the configuration file, the port, the work directory
Returns: the process id.
=cut
#-----------------------------------------------------------------------------
sub start_httpd {
my ( $conf, $port, $dir ) = @_;
my $pid = fork();
die "Unable to fork: $!" unless defined $pid;
if ( $pid == 0 ) {
# prefork signals its whole process group when it stops.
setsid();
open( STDIN, '<', '/dev/null' );
open( STDOUT, '>', "$dir/stdout" );
open( STDERR, '>&', \*STDOUT );
{ exec( $httpd, '-f', $conf, '-DFOREGROUND' ) };
_exit(127);
}
foreach ( 1 .. 100 ) {
if ( waitpid( $pid, WNOHANG ) == $pid ) {
diag( read_log("$dir/stdout") . read_log("$dir/error_log") );
die "Apache exited with status $?";
}
my $sock = IO::Socket::INET->new( PeerAddr => "127.0.0.1:$port" );
return $pid if $sock;
sleep 0.1;
}
stop_httpd($pid);
die "Apache did not listen on port $port";
}
#-----------------------------------------------------------------------------
=head3 stop_httpd
Descriptions: Stops the Apache parent process and reaps it.
Arguments: the process id
Returns: nothing.
=cut
#-----------------------------------------------------------------------------
sub stop_httpd {
my ($pid) = @_;
kill 'TERM', $pid;
waitpid( $pid, 0 );
return;
}
#-----------------------------------------------------------------------------
=head3 read_log
Descriptions: Reads an Apache log for a diagnostic message.
Arguments: the path
Returns: the contents, or an empty string when the file is absent.
=cut
#-----------------------------------------------------------------------------
sub read_log {
my ($path) = @_;
return -e $path ? read_text($path) : '';
}
#-----------------------------------------------------------------------------
=head3 get
Descriptions: Sends one GET request to the private Apache.
Arguments: the port, the request path
Returns: the status code, the Location header and the body.
=cut
#-----------------------------------------------------------------------------
sub get {
my ( $port, $path ) = @_;
my $sock = IO::Socket::INET->new( PeerAddr => "127.0.0.1:$port", Timeout => 10 )
or die "Unable to connect to port $port: $!";
print $sock "GET $path HTTP/1.0\r\nHost: localhost\r\n\r\n";
local $/;
my $response = <$sock>;
close($sock);
my ($status) = $response =~ m{^HTTP/\S+\s+(\d+)};
my ($location) = $response =~ m{^Location:\s*(\S+)}mi;
my ( undef, $body ) = split( /\r\n\r\n/, $response, 2 );
return ( $status // 0, $location // '', $body // '' );
}
#-----------------------------------------------------------------------------
=head3 with_httpd
Descriptions: Runs a block of checks against a private Apache.
Arguments: true to load mod_rewrite, the block, which gets the port
Returns: nothing.
=cut
#-----------------------------------------------------------------------------
sub with_httpd {
my ( $with_rewrite, $code ) = @_;
my $dir = make_tree();
my $port = free_port();
my $conf = write_config( $dir, $port, $with_rewrite );
my $syntax = `$httpd -t -f $conf 2>&1`;
is( $?, 0, ( $with_rewrite ? 'with' : 'without' ) . ' mod_rewrite the configuration parses' )
or diag($syntax);
my $pid = start_httpd( $conf, $port, $dir );
my $ok = eval { $code->($port); 1 };
my $error = $@;
stop_httpd($pid);
die $error unless $ok;
return;
}
my @rest_paths = ( '/xcatws/version', '/xcatwsv2/version', '/xcatws', '/xcatwsv2' );
subtest 'without mod_rewrite' => sub {
with_httpd(
0,
sub {
my ($port) = @_;
foreach my $path (@rest_paths) {
my ( $status, undef, $body ) = get( $port, $path );
is( $status, 403, "$path answers 403" );
unlike( $body, qr/xcatws-stub/, "$path does not run the REST CGI" );
}
my ( $status, undef, $body ) = get( $port, '/install/probe.txt' );
is( $status, 200, '/install is served' );
like( $body, qr/install-probe/, '/install returns the file' );
}
);
};
subtest 'with mod_rewrite' => sub {
with_httpd(
1,
sub {
my ($port) = @_;
foreach my $alias (qw(xcatws xcatwsv2)) {
my ( $status, $location ) = get( $port, "/$alias/version" );
is( $status, 302, "/$alias/version answers 302" );
is( $location, "https://localhost/$alias/version",
"/$alias/version redirects to https" );
}
my ( $status, undef, $body ) = get( $port, '/install/probe.txt' );
is( $status, 200, '/install is served' );
like( $body, qr/install-probe/, '/install returns the file' );
}
);
};
done_testing();