From ff9b74805ceecf9924ee9a4dbca7079a43400286 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:08:12 -0300 Subject: [PATCH 1/4] test(xcat-core): capture the xcat-ws.conf module path and the rpm -V drift `rpm -V xCAT-server` reports /etc/httpd/conf.d/xcat-ws.conf and /etc/apache2/conf.d/xcat-ws.conf changed on every EL, openEuler and SUSE management node, for ever. %install ships the Apache 2.2 fragment as the payload and %post replaces it with the Apache 2.4 fragment on any server that reports 2.4, which is every supported distribution. Both fragments also start with `LoadModule rewrite_module /usr/lib64/apache2-prefork/mod_rewrite.so`. That file is absent on EL and openEuler, and absent on SUSE under any MPM but prefork. Apache skips the line today only because the distribution loaded mod_rewrite first, and logs AH01574. The new test parses each shipped fragment into directives and enclosing sections, and the spec into its scriptlet sections. It asserts the fragment declares no LoadModule and no absolute path, that the redirect and both generations of the authorization directives sit behind their own guard, and that %post never names the installed conf. 22 of its 30 assertions fail on this commit. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-test/unit/wsapi_apache_conf_portable.t | 187 ++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 xCAT-test/unit/wsapi_apache_conf_portable.t diff --git a/xCAT-test/unit/wsapi_apache_conf_portable.t b/xCAT-test/unit/wsapi_apache_conf_portable.t new file mode 100644 index 000000000..31bcc2f0e --- /dev/null +++ b/xCAT-test/unit/wsapi_apache_conf_portable.t @@ -0,0 +1,187 @@ +#!/usr/bin/env perl +# The artifacts under test are an Apache configuration fragment and an RPM spec. +# Neither one executes, and in both the text IS the contract, so this test parses +# them instead of running them: the fragment into directives and enclosing +# sections, the spec into its scriptlet sections. +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::Bin/../lib"; +use Test::More; + +use XCAT::Test::File qw(repo_path slurp_repo_file); + +my $wsapi_dir = 'xCAT-server/xCAT-wsapi'; + +#----------------------------------------------------------------------------- + +=head3 parse_apache_fragment + + Descriptions: Parses an Apache configuration fragment. + Arguments: the fragment contents + Returns: a list of hash references, one per directive, each with the + directive name, its arguments and the enclosing sections from the + outside in. A section is recorded as its name plus its argument, + for example "IfModule mod_rewrite.c". +=cut + +#----------------------------------------------------------------------------- +sub parse_apache_fragment { + my ($contents) = @_; + my (@directives, @open_sections); + + foreach my $line ( split( /\n/, $contents ) ) { + my $text = $line; + $text =~ s/^\s+//; + $text =~ s/\s+$//; + next unless length $text; + next if $text =~ /^#/; + + if ( $text =~ m{^$} ) { + my $closed = pop @open_sections; + die "Unbalanced in an Apache fragment" unless defined $closed; + next; + } + if ( $text =~ m{^<(\w+)\s*(.*?)\s*>$} ) { + my ( $name, $argument ) = ( $1, $2 ); + $argument =~ s/^"(.*)"$/$1/; + push @open_sections, + length($argument) ? "$name $argument" : $name; + next; + } + + my ( $directive, @arguments ) = split( /\s+/, $text ); + push @directives, + { + directive => $directive, + arguments => [@arguments], + sections => [@open_sections], + }; + } + + die 'An Apache fragment left a section open' if @open_sections; + return @directives; +} + +#----------------------------------------------------------------------------- + +=head3 parse_spec_sections + + Descriptions: Splits an RPM spec into its sections. + Arguments: the spec contents + Returns: a hash reference keyed on the section name without the leading + percent sign, each value the lines of that section. +=cut + +#----------------------------------------------------------------------------- +sub parse_spec_sections { + my ($contents) = @_; + my %section; + my $current = 'preamble'; + + foreach my $line ( split( /\n/, $contents ) ) { + if ( $line =~ /^%(build|install|clean|files|pre|post|preun|postun|posttrans|changelog|description|package)\b/ ) { + $current = $1; + $section{$current} ||= []; + next; + } + push @{ $section{$current} ||= [] }, $line; + } + + return \%section; +} + +sub guarded_by { + my ( $directive, $section ) = @_; + return scalar grep { $_ eq $section } @{ $directive->{sections} }; +} + +#----------------------------------------------------------------------------- +# The fragments xCAT-server ships to the RPM families (EL, openEuler, SUSE). +# xcat-ws.conf.ubuntu is excluded on purpose: only debian/rules uses it, and +# Debian keeps its Apache modules in one stable directory on every release. +#----------------------------------------------------------------------------- +my @fragments = sort grep { !m{\.ubuntu$} } + map { my $p = $_; $p =~ s{^.*/(xCAT-server/)}{$1}; $p } + glob( repo_path("$wsapi_dir/xcat-ws.conf*") ); + +die "No Apache fragment found under $wsapi_dir; the test can measure nothing\n" + unless @fragments; + +foreach my $fragment (@fragments) { + my @directives = parse_apache_fragment( slurp_repo_file($fragment) ); + + # The defect: a LoadModule naming one distribution's MPM directory. The + # path is absent on EL and openEuler, and absent on SUSE under any MPM but + # prefork, and a LoadModule whose file is missing stops Apache from starting. + my @load_module = grep { lc( $_->{directive} ) eq 'loadmodule' } @directives; + is( scalar @load_module, 0, + "$fragment declares no LoadModule, so the server owns module loading" ) + or diag( 'LoadModule found: ' + . join( ', ', map { join( ' ', @{ $_->{arguments} } ) } @load_module ) ); + + my @absolute = grep { + my $d = $_; + grep { m{^/} } @{ $d->{arguments} } + } @directives; + is( scalar @absolute, 0, + "$fragment names no absolute filesystem path, so no distribution layout is assumed" ) + or diag( 'absolute path in: ' + . join( ', ', map { $_->{directive} } @absolute ) ); + + # One file must serve Apache 2.2 and 2.4. mod_rewrite may be absent, so the + # redirect is guarded rather than loaded; the authorization directives come + # in both generations' spellings, each behind its own guard. + my @rewrite = grep { lc( $_->{directive} ) =~ /^rewrite/ } @directives; + ok( scalar @rewrite, "$fragment still redirects http to https" ); + foreach my $directive (@rewrite) { + ok( guarded_by( $directive, 'IfModule mod_rewrite.c' ), + "$fragment guards $directive->{directive} with " ); + } + + my @modern = grep { lc( $_->{directive} ) eq 'require' } @directives; + my @legacy = grep { lc( $_->{directive} ) =~ /^(order|allow|deny)$/ } @directives; + ok( scalar @modern, "$fragment grants access in the Apache 2.4 spelling" ); + ok( scalar @legacy, "$fragment grants access in the Apache 2.2 spelling" ); + foreach my $directive (@modern) { + ok( guarded_by( $directive, 'IfModule mod_authz_core.c' ), + "$fragment guards $directive->{directive} with " ); + } + foreach my $directive (@legacy) { + ok( guarded_by( $directive, 'IfModule !mod_authz_core.c' ), + "$fragment guards $directive->{directive} with " ); + } +} + +#----------------------------------------------------------------------------- +# The packaging half. %post must not replace the conf the payload installed: +# every supported distribution runs Apache 2.4, so a replacement always happens +# and "rpm -V xCAT-server" then reports the file changed on every management +# node, for ever. +#----------------------------------------------------------------------------- +my $spec = parse_spec_sections( slurp_repo_file('xCAT-server/xCAT-server.spec') ); + +my @live_paths = + ( '/etc/httpd/conf.d/xcat-ws.conf', '/etc/apache2/conf.d/xcat-ws.conf' ); + +foreach my $path (@live_paths) { + my @post = grep { index( $_, $path ) >= 0 } @{ $spec->{post} }; + is( scalar @post, 0, + "%post never names $path, so it cannot replace the installed payload" ); + diag("%post line: $_") foreach @post; + + my @install = grep { /^\s*cp\s.*\Q$path\E\s*$/ } @{ $spec->{install} }; + ok( scalar @install, "%install installs $path as package payload" ); + + ok( scalar( grep { /^\Q$path\E\s*$/ } @{ $spec->{files} } ), + "%files owns $path, so rpm verifies it" ); +} + +# The LoadModule removed from the fragment is replaced by the distribution's own +# module management. On SUSE apache2 that is a2enmod, as xCAT.spec already does +# for mod_headers; on EL conf.modules.d loads mod_rewrite already. +ok( scalar( grep { /\ba2enmod\s+rewrite\b/ } @{ $spec->{post} } ), + '%post enables mod_rewrite where a2enmod manages modules' ); + +done_testing(); From 74d5184963e6e15ebb502b756f6b83a1f8238513 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:11:09 -0300 Subject: [PATCH 2/4] fix(xcat-core): xcat-ws.conf names a SUSE module path and never matches its package `rpm -V xCAT-server` reports /etc/httpd/conf.d/xcat-ws.conf and /etc/apache2/conf.d/xcat-ws.conf changed on every EL, openEuler and SUSE management node, for the life of the install. %install shipped the Apache 2.2 fragment as the payload, and %post replaced it with the Apache 2.4 fragment whenever httpd, apachectl or apache2ctl reported 2.4. Every supported distribution runs Apache 2.4, so the file on disk never matched the package digest. The drift is a permanent false positive for anyone who verifies an installed xCAT before trusting it. Both fragments also began with `LoadModule rewrite_module /usr/lib64/apache2-prefork/mod_rewrite.so`. That file exists only on SUSE under the prefork MPM. Apache tolerates it on EL and openEuler because conf.modules.d loaded mod_rewrite first, and logs AH01574; where the module is not loaded first, the missing file stops Apache from starting. xcat-ws.conf.apache22 and xcat-ws.conf.apache24 are replaced by one xcat-ws.conf that serves both generations: the redirect sits behind , and the authorization directives behind and its negation. %install copies that one file to both conf.d paths and %post no longer names them, so the payload is the final file. %post runs a2enmod rewrite where a2enmod manages modules, which is how SUSE gets the module the removed LoadModule used to pull in. xCAT-test/unit/wsapi_apache_conf_portable.t fails 22 of its 30 assertions without this change. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-server/xCAT-server.spec | 44 ++++++-------------- xCAT-server/xCAT-wsapi/xcat-ws.conf | 24 +++++++++++ xCAT-server/xCAT-wsapi/xcat-ws.conf.apache22 | 14 ------- xCAT-server/xCAT-wsapi/xcat-ws.conf.apache24 | 10 ----- xCAT-test/unit/wsapi_apache_conf_portable.t | 23 +++------- 5 files changed, 42 insertions(+), 73 deletions(-) create mode 100644 xCAT-server/xCAT-wsapi/xcat-ws.conf delete mode 100644 xCAT-server/xCAT-wsapi/xcat-ws.conf.apache22 delete mode 100644 xCAT-server/xCAT-wsapi/xcat-ws.conf.apache24 diff --git a/xCAT-server/xCAT-server.spec b/xCAT-server/xCAT-server.spec index 48a256080..bb614875f 100644 --- a/xCAT-server/xCAT-server.spec +++ b/xCAT-server/xCAT-server.spec @@ -416,32 +416,26 @@ rm -f $RPM_BUILD_ROOT/%{prefix}/ws/zvmxcatws.cgi %if %fsm %else -echo "ScriptAlias /xcatrhevh %{prefix}/ws/xcatrhevh.cgi" > $RPM_BUILD_ROOT/etc/%httpconfigdir/conf.orig/xcat-ws.conf.apache22 -echo "ScriptAlias /xcatrhevh %{prefix}/ws/xcatrhevh.cgi" > $RPM_BUILD_ROOT/etc/%httpconfigdir/conf.orig/xcat-ws.conf.apache24 +echo "ScriptAlias /xcatrhevh %{prefix}/ws/xcatrhevh.cgi" > $RPM_BUILD_ROOT/etc/%httpconfigdir/conf.orig/xcat-ws.conf %if %notpcm %if %nots390x -echo "ScriptAlias /xcatws %{prefix}/ws/xcatws.cgi" >> $RPM_BUILD_ROOT/etc/%httpconfigdir/conf.orig/xcat-ws.conf.apache22 -echo "ScriptAlias /xcatws %{prefix}/ws/xcatws.cgi" >> $RPM_BUILD_ROOT/etc/%httpconfigdir/conf.orig/xcat-ws.conf.apache24 +echo "ScriptAlias /xcatws %{prefix}/ws/xcatws.cgi" >> $RPM_BUILD_ROOT/etc/%httpconfigdir/conf.orig/xcat-ws.conf %else # Add in old version 1 REST-API and version 2 REST-API to z/VM, default to version 1 -echo "ScriptAlias /xcatwsv2 %{prefix}/ws/xcatws.cgi" >> $RPM_BUILD_ROOT/etc/%httpconfigdir/conf.orig/xcat-ws.conf.apache22 -echo "ScriptAlias /xcatwsv2 %{prefix}/ws/xcatws.cgi" >> $RPM_BUILD_ROOT/etc/%httpconfigdir/conf.orig/xcat-ws.conf.apache24 -echo "ScriptAlias /xcatws %{prefix}/ws/zvmxcatws.cgi" >> $RPM_BUILD_ROOT/etc/%httpconfigdir/conf.orig/xcat-ws.conf.apache22 -echo "ScriptAlias /xcatws %{prefix}/ws/zvmxcatws.cgi" >> $RPM_BUILD_ROOT/etc/%httpconfigdir/conf.orig/xcat-ws.conf.apache24 +echo "ScriptAlias /xcatwsv2 %{prefix}/ws/xcatws.cgi" >> $RPM_BUILD_ROOT/etc/%httpconfigdir/conf.orig/xcat-ws.conf +echo "ScriptAlias /xcatws %{prefix}/ws/zvmxcatws.cgi" >> $RPM_BUILD_ROOT/etc/%httpconfigdir/conf.orig/xcat-ws.conf %endif %endif -cat $RPM_BUILD_ROOT/%{prefix}/ws/xcat-ws.conf.apache22 >> $RPM_BUILD_ROOT/etc/%httpconfigdir/conf.orig/xcat-ws.conf.apache22 -cat $RPM_BUILD_ROOT/%{prefix}/ws/xcat-ws.conf.apache24 >> $RPM_BUILD_ROOT/etc/%httpconfigdir/conf.orig/xcat-ws.conf.apache24 -#install lower version(<2.4) apache/httpd conf files by default -cp $RPM_BUILD_ROOT/etc/%httpconfigdir/conf.orig/xcat-ws.conf.apache22 $RPM_BUILD_ROOT/etc/apache2/conf.d/xcat-ws.conf -cp $RPM_BUILD_ROOT/etc/%httpconfigdir/conf.orig/xcat-ws.conf.apache22 $RPM_BUILD_ROOT/etc/httpd/conf.d/xcat-ws.conf +cat $RPM_BUILD_ROOT/%{prefix}/ws/xcat-ws.conf >> $RPM_BUILD_ROOT/etc/%httpconfigdir/conf.orig/xcat-ws.conf +# One fragment serves Apache 2.2 and 2.4, so the payload is also the final file. +cp $RPM_BUILD_ROOT/etc/%httpconfigdir/conf.orig/xcat-ws.conf $RPM_BUILD_ROOT/etc/apache2/conf.d/xcat-ws.conf +cp $RPM_BUILD_ROOT/etc/%httpconfigdir/conf.orig/xcat-ws.conf $RPM_BUILD_ROOT/etc/httpd/conf.d/xcat-ws.conf %endif -rm -f $RPM_BUILD_ROOT/%{prefix}/ws/xcat-ws.conf.apache22 -rm -f $RPM_BUILD_ROOT/%{prefix}/ws/xcat-ws.conf.apache24 +rm -f $RPM_BUILD_ROOT/%{prefix}/ws/xcat-ws.conf %clean rm -rf $RPM_BUILD_ROOT @@ -605,23 +599,9 @@ if [ "$1" -gt "1" ]; then #only on upgrade for AIX... fi %endif -#Apply the correct httpd/apache configuration file according to the httpd/apache version -if [ -n "$(httpd -v 2>&1 |grep -e '^Server version\s*:.*/2\.4')" ] -then - rm -rf /etc/httpd/conf.d/xcat-ws.conf - cp /etc/%httpconfigdir/conf.orig/xcat-ws.conf.apache24 /etc/httpd/conf.d/xcat-ws.conf -fi - -if [ -n "$(apachectl -v 2>&1 |grep -e '^Server version\s*:.*/2\.4')" ] -then - rm -rf /etc/apache2/conf.d/xcat-ws.conf - cp /etc/%httpconfigdir/conf.orig/xcat-ws.conf.apache24 /etc/apache2/conf.d/xcat-ws.conf -fi - -if [ -n "$(apache2ctl -v 2>&1 |grep -e '^Server version\s*:.*/2\.4')" ] -then - rm -rf /etc/apache2/conf.d/xcat-ws.conf - cp /etc/%httpconfigdir/conf.orig/xcat-ws.conf.apache24 /etc/apache2/conf.d/xcat-ws.conf +# SUSE does not load mod_rewrite by default. A no-op where a2enmod is absent. +if command -v a2enmod >/dev/null 2>&1; then + a2enmod rewrite >/dev/null 2>&1 || : fi exit 0 diff --git a/xCAT-server/xCAT-wsapi/xcat-ws.conf b/xCAT-server/xCAT-wsapi/xcat-ws.conf new file mode 100644 index 000000000..aefeb45b6 --- /dev/null +++ b/xCAT-server/xCAT-wsapi/xcat-ws.conf @@ -0,0 +1,24 @@ +# The Apache configuration for the xCAT REST API, for every RPM family. +# +# Do not add a LoadModule: it names one distribution's module directory and is +# then absent on the others, which stops Apache from starting. + +# Redirect http to https. Guarded because mod_rewrite can be absent. + +RewriteEngine On +RewriteCond %{SERVER_PORT} 80 +RewriteCond %{HTTPS} !=on +RewriteRule ^/?xcatws/(.*) https://%{SERVER_NAME}/xcatws/$1 [R,L] +RewriteRule ^/?xcatwsv2/(.*) https://%{SERVER_NAME}/xcatwsv2/$1 [R,L] + + + +# Both spellings, so one file serves Apache 2.2 and 2.4. + +Require all granted + + +Order allow,deny +Allow from all + + diff --git a/xCAT-server/xCAT-wsapi/xcat-ws.conf.apache22 b/xCAT-server/xCAT-wsapi/xcat-ws.conf.apache22 deleted file mode 100644 index a8432e979..000000000 --- a/xCAT-server/xCAT-wsapi/xcat-ws.conf.apache22 +++ /dev/null @@ -1,14 +0,0 @@ -LoadModule rewrite_module /usr/lib64/apache2-prefork/mod_rewrite.so - -# Redirect all http request to https -RewriteEngine On -RewriteCond %{SERVER_PORT} 80 -RewriteCond %{HTTPS} !=on -RewriteRule ^/?xcatws/(.*) https://%{SERVER_NAME}/xcatws/$1 [R,L] -RewriteRule ^/?xcatwsv2/(.*) https://%{SERVER_NAME}/xcatwsv2/$1 [R,L] - - - Order allow,deny - Allow from all - - diff --git a/xCAT-server/xCAT-wsapi/xcat-ws.conf.apache24 b/xCAT-server/xCAT-wsapi/xcat-ws.conf.apache24 deleted file mode 100644 index 7c54e25bc..000000000 --- a/xCAT-server/xCAT-wsapi/xcat-ws.conf.apache24 +++ /dev/null @@ -1,10 +0,0 @@ -LoadModule rewrite_module /usr/lib64/apache2-prefork/mod_rewrite.so -RewriteEngine On -RewriteCond %{SERVER_PORT} 80 -RewriteCond %{HTTPS} !=on -RewriteRule ^/?xcatws/(.*) https://%{SERVER_NAME}/xcatws/$1 [R,L] -RewriteRule ^/?xcatwsv2/(.*) https://%{SERVER_NAME}/xcatwsv2/$1 [R,L] - - -Require all granted - diff --git a/xCAT-test/unit/wsapi_apache_conf_portable.t b/xCAT-test/unit/wsapi_apache_conf_portable.t index 31bcc2f0e..2e2587047 100644 --- a/xCAT-test/unit/wsapi_apache_conf_portable.t +++ b/xCAT-test/unit/wsapi_apache_conf_portable.t @@ -1,8 +1,6 @@ #!/usr/bin/env perl -# The artifacts under test are an Apache configuration fragment and an RPM spec. -# Neither one executes, and in both the text IS the contract, so this test parses -# them instead of running them: the fragment into directives and enclosing -# sections, the spec into its scriptlet sections. +# The fragment and the spec do not execute and their text IS the contract, so this +# test parses them rather than running them. use strict; use warnings; @@ -98,9 +96,7 @@ sub guarded_by { } #----------------------------------------------------------------------------- -# The fragments xCAT-server ships to the RPM families (EL, openEuler, SUSE). -# xcat-ws.conf.ubuntu is excluded on purpose: only debian/rules uses it, and -# Debian keeps its Apache modules in one stable directory on every release. +# The RPM families only. debian/rules owns xcat-ws.conf.ubuntu. #----------------------------------------------------------------------------- my @fragments = sort grep { !m{\.ubuntu$} } map { my $p = $_; $p =~ s{^.*/(xCAT-server/)}{$1}; $p } @@ -112,9 +108,7 @@ die "No Apache fragment found under $wsapi_dir; the test can measure nothing\n" foreach my $fragment (@fragments) { my @directives = parse_apache_fragment( slurp_repo_file($fragment) ); - # The defect: a LoadModule naming one distribution's MPM directory. The - # path is absent on EL and openEuler, and absent on SUSE under any MPM but - # prefork, and a LoadModule whose file is missing stops Apache from starting. + # A LoadModule naming one distribution's MPM directory is absent on the others. my @load_module = grep { lc( $_->{directive} ) eq 'loadmodule' } @directives; is( scalar @load_module, 0, "$fragment declares no LoadModule, so the server owns module loading" ) @@ -130,9 +124,7 @@ foreach my $fragment (@fragments) { or diag( 'absolute path in: ' . join( ', ', map { $_->{directive} } @absolute ) ); - # One file must serve Apache 2.2 and 2.4. mod_rewrite may be absent, so the - # redirect is guarded rather than loaded; the authorization directives come - # in both generations' spellings, each behind its own guard. + # One file serves both generations, each directive behind its own guard. my @rewrite = grep { lc( $_->{directive} ) =~ /^rewrite/ } @directives; ok( scalar @rewrite, "$fragment still redirects http to https" ); foreach my $directive (@rewrite) { @@ -155,10 +147,7 @@ foreach my $fragment (@fragments) { } #----------------------------------------------------------------------------- -# The packaging half. %post must not replace the conf the payload installed: -# every supported distribution runs Apache 2.4, so a replacement always happens -# and "rpm -V xCAT-server" then reports the file changed on every management -# node, for ever. +# %post must not replace the payload, or rpm -V reports drift for the life of the install. #----------------------------------------------------------------------------- my $spec = parse_spec_sections( slurp_repo_file('xCAT-server/xCAT-server.spec') ); From 4f6938c6b2f33d9799fe14ed85291810e38e204a Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:38:52 -0300 Subject: [PATCH 3/4] test(xcat-core): check the REST aliases when mod_rewrite is not loaded xcat-ws.conf keeps its https redirect inside an IfModule mod_rewrite.c block. No test starts Apache with the fragment, so nothing checks what the REST aliases answer when that module is not loaded. wsapi_apache_access.t starts a private Apache on a free port with the fragment from the source tree. Without mod_rewrite it expects 403 from /xcatws and /xcatwsv2. With mod_rewrite it expects the 302 to https. In both cases /install is still served. The Debian packages do not install xcat-ws.conf, so the GitHub action runs the test from the source tree after install. The installed copy skips. The test fails without mod_rewrite: both aliases answer 200 and run the CGI. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- github_action_xcat_test.pl | 36 ++ .../autotest/testcase/integration/cases0 | 2 +- xCAT-test/integration/README.md | 5 +- xCAT-test/integration/wsapi_apache_access.t | 366 ++++++++++++++++++ 4 files changed, 406 insertions(+), 3 deletions(-) create mode 100644 xCAT-test/integration/wsapi_apache_access.t diff --git a/github_action_xcat_test.pl b/github_action_xcat_test.pl index 847290e14..74ef58b35 100644 --- a/github_action_xcat_test.pl +++ b/github_action_xcat_test.pl @@ -498,6 +498,34 @@ sub run_bats_tests{ return 0; } +#-------------------------------------------------------- +# Fuction name: run_source_integration_tests +# Description: Run the integration tests that read a file the Debian +# packages do not install. They run against the pre-build copy +# of the source tree, after install_xcat() put Apache on the +# runner. A skip fails the phase: the runner has every +# prerequisite. +# Attributes: +# Return code: 0 all tests passed, 1 otherwise +#-------------------------------------------------------- +sub run_source_integration_tests{ + my $cmd = "cd $unitsrc && prove -v xCAT-test/integration/wsapi_apache_access.t"; + print "[run_source_integration_tests] running $cmd\n"; + my @output = runcmd("$cmd"); + print Dumper \@output; + if($::RUNCMD_RC or grep { /skipped/i } @output){ + print RED "[run_source_integration_tests] $cmd ....[Failed]\n"; + $check_result_str .= "> **SOURCE INTEGRATION TESTS Failed** : Please click ``Details`` label in ``Merge pull request`` box for detailed information\n"; + print $check_result_str; + return 1; + } + + print "[run_source_integration_tests] $cmd ....[Pass]\n"; + $check_result_str .= "> **SOURCE INTEGRATION TESTS Successful**\n"; + print $check_result_str; + return 0; +} + #-------------------------------------------------------- # Fuction name: run_blackboxtest_unit_tests # Description: Run the blackboxtest offline checks: its Python unit tests and @@ -1017,6 +1045,14 @@ if($rst){ } mark_time("run_bats_tests"); +print GREEN "\n------Running xCAT-test source integration tests ------\n"; +$rst = run_source_integration_tests(); +if($rst){ + print RED "Run of xCAT-test source integration tests failed\n"; + exit $rst; +} +mark_time("run_source_integration_tests"); + #Check the syntax of changing code print GREEN "\n------ Checking the syntax of changed code------\n"; $rst = check_syntax(); diff --git a/xCAT-test/autotest/testcase/integration/cases0 b/xCAT-test/autotest/testcase/integration/cases0 index efaee1921..115e7030b 100644 --- a/xCAT-test/autotest/testcase/integration/cases0 +++ b/xCAT-test/autotest/testcase/integration/cases0 @@ -4,5 +4,5 @@ os:Linux label:mn_only,ci_test,integration cmd:prove -I/opt/xcat/lib/perl -I/opt/xcat/lib/perl/xCAT -r /opt/xcat/share/xcat/tools/autotest/integration check:rc==0 -check:output=~Files=7, +check:output=~Files=8, end diff --git a/xCAT-test/integration/README.md b/xCAT-test/integration/README.md index 847a3a300..1b5a01b60 100644 --- a/xCAT-test/integration/README.md +++ b/xCAT-test/integration/README.md @@ -27,8 +27,8 @@ against it. Note the `-I` flags: unlike the unit tests these run from the installed location, so they pick up xCAT modules from `/opt/xcat/lib/perl` rather than from a source tree. -The case checks `rc==0` and `output=~Files=7,`. The file count checks that `prove` -found all seven files, which catches a packaging regression or a test renamed away from +The case checks `rc==0` and `output=~Files=8,`. The file count checks that `prove` +found all eight files, which catches a packaging regression or a test renamed away from `.t` without updating the count. It does not prove that environment-gated cases ran; inspect the TAP output for skips. **Add to that number when you add a test.** A missing directory is already caught by `rc==0` -- `prove -r` on a path that does not exist exits @@ -53,6 +53,7 @@ A test belongs in `integration/` when it needs something the checkout cannot pro | `html_form_runtime_dependency.t` | the installed `HTML::Form` module | | `openeuler_package_postscripts.t` | Linux root, private mount namespaces and DNF; tests complete package postscripts with command doubles and an isolated repository directory | | `openeuler_postscript_repositories.t` | Linux root, private mount namespaces, DNF and RPM signing tools; tests actual signed repository transactions in isolated install roots | +| `wsapi_apache_access.t` | an Apache binary and its modules; reads `xcat-ws.conf` from the source tree, so `github_action_xcat_test.pl` runs it from the checkout and the installed copy skips | ## Environment guards diff --git a/xCAT-test/integration/wsapi_apache_access.t b/xCAT-test/integration/wsapi_apache_access.t new file mode 100644 index 000000000..f0a9cf27c --- /dev/null +++ b/xCAT-test/integration/wsapi_apache_access.t @@ -0,0 +1,366 @@ +#!/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(); From 6bc7800bba8eddd874ca95ea9a70da4c299bd3d1 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:39:18 -0300 Subject: [PATCH 4/4] fix(xcat-core): deny the REST aliases when mod_rewrite is not loaded xcat-ws.conf sends /xcatws and /xcatwsv2 to https only when mod_rewrite is loaded. Without that module the aliases run the REST CGI on any port. An IfModule !mod_rewrite.c block now answers 403 for both aliases. RedirectMatch comes from mod_alias, which the ScriptAlias lines of the same file already require. Other paths, such as /install, are not changed. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-server/xCAT-wsapi/xcat-ws.conf | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/xCAT-server/xCAT-wsapi/xcat-ws.conf b/xCAT-server/xCAT-wsapi/xcat-ws.conf index aefeb45b6..64cc7612a 100644 --- a/xCAT-server/xCAT-wsapi/xcat-ws.conf +++ b/xCAT-server/xCAT-wsapi/xcat-ws.conf @@ -11,6 +11,10 @@ RewriteCond %{HTTPS} !=on RewriteRule ^/?xcatws/(.*) https://%{SERVER_NAME}/xcatws/$1 [R,L] RewriteRule ^/?xcatwsv2/(.*) https://%{SERVER_NAME}/xcatwsv2/$1 [R,L] +# Without the redirect, deny the REST aliases. + +RedirectMatch 403 ^/xcatws(v2)?(/|$) + # Both spellings, so one file serves Apache 2.2 and 2.4.