From 0f2b66cfa3684e497a141c8b6b621b45cbc7f0e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Ka=C5=A1=C3=ADk?= Date: Fri, 29 May 2026 15:15:55 +0200 Subject: [PATCH 1/7] Fix mTLS config written to wrong path on native httpd getModProxyClusterConfPath() returned a relative path, which copyLocalFile() then resolved against httpdHome, doubling the path. The SSL variant config silently went to a nested location while httpd kept serving plain HTTP on the MCMP port. --- .../modcluster/test/utils/balancer/NativeHttpdBalancer.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java b/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java index 9564eae..4e6decf 100644 --- a/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java +++ b/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java @@ -184,7 +184,8 @@ public String getConfDir() { @Override public String getModProxyClusterConfPath() { - return confFile.getParent().getParent().resolve("conf.d").resolve("mod_proxy_cluster.conf").toString(); + return confFile.getParent().getParent().resolve("conf.d").resolve("mod_proxy_cluster.conf") + .toAbsolutePath().toString(); } @Override From fbabcc6ed8c2fc96dcabe772677c7ab53d1f45ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Ka=C5=A1=C3=ADk?= Date: Mon, 1 Jun 2026 09:29:54 +0200 Subject: [PATCH 2/7] Clean up NativeHttpdBalancer path handling and add CRL test to CI Replace ad-hoc confFile.getParent().getParent() navigation with httpdHome field, add requireHttpdHome() guard for fail-fast on pre-start access, fix RHEL8 JBCS layout support (httpd/sbin/httpd search path), remove misleading log and redundant method call, replace var with explicit types, and add SslCrlTest to CI matrix. --- .github/workflows/ci.yml | 2 +- .../utils/balancer/NativeHttpdBalancer.java | 42 +++++++++++-------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b560549..ec7c2c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: env: WILDFLY_VERSION: 39.0.1.Final - TEST_CLASS: StickySessionTest,SslFailoverTest + TEST_CLASS: StickySessionTest,SslFailoverTest,SslCrlTest jobs: test: diff --git a/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java b/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java index 4e6decf..94b5045 100644 --- a/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java +++ b/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java @@ -25,6 +25,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Stream; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; @@ -87,7 +88,6 @@ public void start() { } patchHttpdConf(); - copyModProxyClusterConf(); removeConflictingConfigs(); Files.createDirectories(confFile.getParent().resolve("extra")); @@ -174,17 +174,18 @@ public int getManagementPort() { @Override public String getServerHome() { - return httpdHome != null ? httpdHome.toAbsolutePath().toString() : null; + return requireHttpdHome().toAbsolutePath().toString(); } @Override public String getConfDir() { - return confFile != null ? confFile.getParent().toAbsolutePath().toString() : super.getConfDir(); + requireHttpdHome(); + return confFile.getParent().toAbsolutePath().toString(); } @Override public String getModProxyClusterConfPath() { - return confFile.getParent().getParent().resolve("conf.d").resolve("mod_proxy_cluster.conf") + return requireHttpdHome().resolve("conf.d").resolve("mod_proxy_cluster.conf") .toAbsolutePath().toString(); } @@ -207,11 +208,12 @@ public int getMcmpSslPort() { @Override public CommandResult execCommand(String... command) throws Exception { - return NativeProcessManager.execCommand(httpdHome, command); + return NativeProcessManager.execCommand(requireHttpdHome(), command); } @Override public void copyClasspathResource(String classpathResource, String destPath) { + requireHttpdHome(); try { Path dest = Path.of(destPath); if (!dest.isAbsolute()) { @@ -234,6 +236,7 @@ public void copyClasspathResource(String classpathResource, String destPath) { @Override public void copyLocalFile(Path hostPath, String destPath) { + requireHttpdHome(); try { Path dest = Path.of(destPath); if (!dest.isAbsolute()) { @@ -422,6 +425,13 @@ public void reload() throws Exception { // ---- Private helpers ---- + private Path requireHttpdHome() { + if (httpdHome == null) { + throw new IllegalStateException("NativeHttpdBalancer has not been started"); + } + return httpdHome; + } + /** * Find a JBCS httpd distribution ZIP in the {@code distributions/} directory. * @@ -525,7 +535,7 @@ private Path findHttpdBinary(Path home) { private static final List HTTPD_BINARY_SEARCH_PATHS = TestMode.isWindows() ? List.of("bin/httpd.exe", "sbin/httpd.exe", "httpd/bin/httpd.exe", "httpd/sbin/httpd.exe") - : List.of("sbin/httpd", "bin/httpd"); + : List.of("sbin/httpd", "bin/httpd", "httpd/sbin/httpd", "httpd/bin/httpd"); private Path findHttpdBinaryOrNull(Path home) { for (String candidate : HTTPD_BINARY_SEARCH_PATHS) { @@ -548,7 +558,7 @@ private Path findHttpdConf(Path home) { conf = home.resolve("etc/httpd/conf/httpd.conf"); if (Files.isRegularFile(conf)) return conf; - try (var stream = Files.walk(home)) { + try (Stream stream = Files.walk(home)) { Path found = stream .filter(p -> p.getFileName().toString().equals("httpd.conf")) .filter(Files::isRegularFile) @@ -562,7 +572,6 @@ private Path findHttpdConf(Path home) { log.warn("Error searching for httpd.conf in {}", home, e); } - log.info("No httpd.conf found under {}; will generate one", home); return null; } @@ -639,10 +648,10 @@ private void patchHttpdConf() throws IOException { * mod_proxy_balancer conflicts with mod_proxy_cluster and must not be loaded. */ private void disableProxyBalancerInFragments() throws IOException { - Path confModulesD = confFile.getParent().getParent().resolve("conf.modules.d"); + Path confModulesD = httpdHome.resolve("conf.modules.d"); if (!Files.isDirectory(confModulesD)) return; - try (var stream = Files.list(confModulesD)) { + try (Stream stream = Files.list(confModulesD)) { for (Path fragment : stream.filter(p -> p.toString().endsWith(".conf")).toList()) { String content = Files.readString(fragment); if (content.contains("LoadModule proxy_balancer_module")) { @@ -657,10 +666,10 @@ private void disableProxyBalancerInFragments() throws IOException { } /** - * Copy mod_proxy_cluster.conf from classpath to httpd conf/extra/. + * Copy mod_proxy_cluster.conf from classpath to httpd conf.d/. */ private void copyModProxyClusterConf() throws IOException { - Path destDir = confFile.getParent().getParent().resolve("conf.d"); + Path destDir = httpdHome.resolve("conf.d"); Files.createDirectories(destDir); Path dest = destDir.resolve("mod_proxy_cluster.conf"); @@ -765,7 +774,7 @@ private void extractOverlayZip(Path zipPath, Path targetDir) throws IOException * the connectors' version; this method handles the separate native config file. */ private void removeConflictingConfigs() throws IOException { - Path confD = confFile.getParent().getParent().resolve("conf.d"); + Path confD = httpdHome.resolve("conf.d"); if (!Files.isDirectory(confD)) return; Path modClusterNative = confD.resolve("mod_cluster-native.conf"); @@ -805,16 +814,15 @@ private void logHttpdDiagnostics() { } } - if (confFile != null) { - Path serverRoot = confFile.getParent().getParent(); - Path modulesDir = serverRoot.resolve("modules"); + if (httpdHome != null) { + Path modulesDir = httpdHome.resolve("modules"); for (String module : List.of("mod_manager.so", "mod_proxy_cluster.so", "mod_advertise.so", "mod_lbmethod_cluster.so")) { Path p = modulesDir.resolve(module); log.error(" {} -> {}", module, Files.isRegularFile(p) ? "PRESENT" : "MISSING"); } - Path errorLog = serverRoot.resolve("logs/error_log"); + Path errorLog = httpdHome.resolve("logs/error_log"); if (Files.isRegularFile(errorLog)) { try { String errors = Files.readString(errorLog); From 83348977eb6257af792dfd6cd2e6d6b7c09fb373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Ka=C5=A1=C3=ADk?= Date: Mon, 1 Jun 2026 09:35:58 +0200 Subject: [PATCH 3/7] Add requireConfFile() guard for confFile access Guard public methods that dereference confFile (getConfDir, reload) with a dedicated requireConfFile() helper, matching the existing requireHttpdHome() pattern. --- .../test/utils/balancer/NativeHttpdBalancer.java | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java b/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java index 94b5045..bf8aa57 100644 --- a/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java +++ b/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java @@ -179,8 +179,7 @@ public String getServerHome() { @Override public String getConfDir() { - requireHttpdHome(); - return confFile.getParent().toAbsolutePath().toString(); + return requireConfFile().getParent().toAbsolutePath().toString(); } @Override @@ -399,15 +398,17 @@ public void reload() throws Exception { log.info("Reloading httpd balancer (graceful restart)"); if (TestMode.isWindows()) { processManager.stop(); + Path conf = requireConfFile(); List command = List.of( httpdBinary.toAbsolutePath().toString(), - "-f", confFile.toAbsolutePath().toString(), + "-f", conf.toAbsolutePath().toString(), "-DFOREGROUND"); processManager = new NativeProcessManager("httpd-balancer", command, httpdHome, null); processManager.start(); } else { + Path conf = requireConfFile(); CommandResult result = execCommand(httpdBinary.toAbsolutePath().toString(), - "-f", confFile.toAbsolutePath().toString(), "-k", "graceful"); + "-f", conf.toAbsolutePath().toString(), "-k", "graceful"); if (!result.isSuccess()) { log.warn("httpd graceful restart returned exit code {}: {}", result.getExitCode(), result.getStderr()); @@ -432,6 +433,13 @@ private Path requireHttpdHome() { return httpdHome; } + private Path requireConfFile() { + if (confFile == null) { + throw new IllegalStateException("NativeHttpdBalancer has not been started"); + } + return confFile; + } + /** * Find a JBCS httpd distribution ZIP in the {@code distributions/} directory. * From bb667c368d296a3b5aa45d7ea45e9d405a40abb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Ka=C5=A1=C3=ADk?= Date: Mon, 1 Jun 2026 10:47:58 +0200 Subject: [PATCH 4/7] Add system httpd support and native httpd CI job Support system-installed httpd via -Dhttpd.home (e.g. /usr) with externally-built mod_proxy_cluster modules via -Dhttpd.modules.path. A working directory under target/native-servers/httpd/work/ is created with generated httpd.conf, merged modules symlinks, and conf.d/ for the mod_proxy_cluster config template. Add native-httpd CI job on Ubuntu that installs apache2, builds mod_proxy_cluster from source via CMake, and runs the test suite. Update all CI actions to latest versions (checkout v6, setup-java v5, upload-artifact v7, action-junit-report v6). Document new properties and system httpd setup in README. --- .github/workflows/ci.yml | 65 ++++- README.md | 58 +++- distributions/README.md | 20 ++ .../utils/balancer/NativeHttpdBalancer.java | 261 +++++++++++++++--- 4 files changed, 360 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec7c2c1..cddd569 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: env: WILDFLY_VERSION: 39.0.1.Final TEST_CLASS: StickySessionTest,SslFailoverTest,SslCrlTest + MOD_PROXY_CLUSTER_REPO: https://github.com/modcluster/mod_proxy_cluster.git jobs: test: @@ -27,10 +28,10 @@ jobs: name: "${{ matrix.balancer }} / ${{ matrix.os }} / JDK ${{ matrix.java }}" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up JDK ${{ matrix.java }} - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: java-version: ${{ matrix.java }} distribution: temurin @@ -51,14 +52,70 @@ jobs: run: mvn -B test -Pnative -Dtest=${{ env.TEST_CLASS }} -DexcludedGroups=none -Dbalancer.type=${{ matrix.balancer }} -Dwildfly.version=${{ env.WILDFLY_VERSION }} - name: Publish test results - uses: mikepenz/action-junit-report@v5 + uses: mikepenz/action-junit-report@v6 if: always() with: report_paths: '**/target/surefire-reports/*.xml' - name: Upload surefire reports - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: failure() with: name: surefire-reports-${{ matrix.os }}-${{ matrix.balancer }}-jdk${{ matrix.java }} path: target/surefire-reports/ + + native-httpd: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + java: ['17', '21'] + + name: "native httpd / ubuntu / JDK ${{ matrix.java }}" + + steps: + - uses: actions/checkout@v6 + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v5 + with: + java-version: ${{ matrix.java }} + distribution: temurin + cache: 'maven' + + - name: Install httpd and build dependencies + run: | + sudo apt-get update + sudo apt-get install -y apache2-dev libapr1-dev libaprutil1-dev cmake gcc + + - name: Build mod_proxy_cluster modules + run: | + git clone --depth 1 ${{ env.MOD_PROXY_CLUSTER_REPO }} target/mod_proxy_cluster + cmake -S target/mod_proxy_cluster/native -B target/mod_proxy_cluster/native/build -DCMAKE_BUILD_TYPE=Debug + make -C target/mod_proxy_cluster/native/build -j$(nproc) + + - name: Download WildFly via Maven + run: mvn -B generate-test-resources -Pdownload-wildfly -Dwildfly.version=${{ env.WILDFLY_VERSION }} -DskipTests + + - name: Run tests + run: | + mvn -B test -Pnative \ + -Dtest=${{ env.TEST_CLASS }} \ + -DexcludedGroups=none \ + -Dbalancer.type=httpd \ + -Dhttpd.home=/usr \ + -Dhttpd.modules.path=${{ github.workspace }}/target/mod_proxy_cluster/native/build/modules \ + -Dwildfly.version=${{ env.WILDFLY_VERSION }} + + - name: Publish test results + uses: mikepenz/action-junit-report@v6 + if: always() + with: + report_paths: '**/target/surefire-reports/*.xml' + + - name: Upload surefire reports + uses: actions/upload-artifact@v7 + if: failure() + with: + name: surefire-reports-native-httpd-jdk${{ matrix.java }} + path: target/surefire-reports/ diff --git a/README.md b/README.md index e68084a..456c5fe 100644 --- a/README.md +++ b/README.md @@ -150,12 +150,41 @@ Run tests without Docker/Podman by starting WildFly and httpd as local OS proces # Undertow balancer (default) mvn test -Pnative -Dwildfly.zip.path=distributions/wildfly-39.0.1.Final.zip -# httpd balancer +# httpd balancer (JBCS ZIP) mvn test -Pnative -Dbalancer.type=httpd \ -Dwildfly.zip.path=distributions/wildfly-39.0.1.Final.zip \ -Dhttpd.zip.path=distributions/jbcs-httpd24-2.4.62-win-x86_64.zip ``` +#### System httpd (no ZIP required) + +You can use a system-installed httpd instead of a JBCS ZIP. This requires building +mod_proxy_cluster modules from source. + +**Prerequisites** (Fedora/RHEL): +```bash +sudo dnf install httpd httpd-devel apr-devel apr-util-devel mod_ssl cmake gcc +``` + +**Prerequisites** (Debian/Ubuntu): +```bash +sudo apt-get install apache2-dev libapr1-dev libaprutil1-dev cmake gcc +``` + +**Build mod_proxy_cluster modules:** +```bash +git clone --depth 1 https://github.com/modcluster/mod_proxy_cluster.git target/mod_proxy_cluster +cmake -S target/mod_proxy_cluster/native -B target/mod_proxy_cluster/native/build -DCMAKE_BUILD_TYPE=Debug +make -C target/mod_proxy_cluster/native/build -j$(nproc) +``` + +**Run tests:** +```bash +mvn test -Pnative -Dbalancer.type=httpd \ + -Dhttpd.home=/usr \ + -Dhttpd.modules.path=$PWD/target/mod_proxy_cluster/native/build/modules +``` + The `-Pnative` profile sets `-Dtest.mode=native` and excludes `@Tag("docker")` and `@Tag("soak")` tests. See [TESTING.md](TESTING.md) for details on port allocation and server lifecycle. ### Run specific test class @@ -354,9 +383,13 @@ This is transparent to the tests — JGroups handles internal session replicatio - **Without ZIP**: Falls back to a pre-built image (placeholder: `quay.io/modcluster/mod_cluster-undertow:latest` — does not exist yet, provide your own via `-Dbalancer.undertow.image=`) - Customizable via `-Dbalancer.undertow.image=` - **httpd balancer**: - - **With httpd ZIP** (`-Dhttpd.zip.path=`): Builds from a pre-built httpd ZIP (e.g. JBCS). Auto-detects RHEL version from ZIP filename for the base image. - - **Without ZIP**: Builds httpd from source and compiles mod_proxy_cluster modules (uses `fedora:42` as base) - - **Pre-built image**: Override with `-Dbalancer.httpd.image=` to skip building entirely + - **Docker mode** (default): + - **With httpd ZIP** (`-Dhttpd.zip.path=`): Builds from a pre-built httpd ZIP (e.g. JBCS). Auto-detects RHEL version from ZIP filename for the base image. + - **Without ZIP**: Builds httpd from source and compiles mod_proxy_cluster modules (uses `fedora:42` as base) + - **Pre-built image**: Override with `-Dbalancer.httpd.image=` to skip building entirely + - **Native mode**: + - **System httpd** (`-Dhttpd.home=/usr`): Uses system-installed httpd with externally-built mod_proxy_cluster modules (`-Dhttpd.modules.path=`) + - **JBCS ZIP** (`-Dhttpd.zip.path=`): Extracts and runs directly as a local process ### ZIP Distribution Priority 1. System property: `-Dwildfly.zip.path=/path/to/wildfly.zip` @@ -376,6 +409,23 @@ Default fallback images (when no ZIP provided). The `quay.io/modcluster/` images In practice, always provide a WildFly/EAP ZIP — the fallback images are not published. +## Configuration Properties + +| Property | Mode | Default | Description | +|---|---|---|---| +| `test.mode` | All | `docker` | `docker` or `native` | +| `balancer.type` | All | `undertow` | `undertow` or `httpd` | +| `wildfly.zip.path` | All | auto-detect in `distributions/` | Path to WildFly/EAP ZIP | +| `wildfly.version` | Docker | — | WildFly version to download from Maven Central | +| `httpd.home` | Native | derived from ZIP extraction | Path to httpd installation root (e.g. `/usr`) | +| `httpd.zip.path` | Both | auto-detect in `distributions/` | Path to JBCS httpd ZIP | +| `httpd.connectors.zip.path` | Native | auto-detect alongside httpd ZIP | Path to JBCS connectors ZIP | +| `httpd.modules.path` | Native | `httpdHome/modules` | Directory containing mod_proxy_cluster `.so` files | +| `httpd.version` | Docker | `2.4.66` | httpd version for Docker source build | +| `balancer.httpd.image` | Docker | built automatically | Custom Docker image for httpd balancer | +| `balancer.undertow.image` | Docker | built from WildFly ZIP | Custom Docker image for Undertow balancer | +| `mod.proxy.cluster.repo.url` | Docker | `https://github.com/modcluster/mod_proxy_cluster.git` | mod_proxy_cluster source repo | + ## Contributing When adding new tests: diff --git a/distributions/README.md b/distributions/README.md index 9fed2e6..870ca04 100644 --- a/distributions/README.md +++ b/distributions/README.md @@ -58,6 +58,26 @@ To test with specific versions, either: - Keep only one ZIP in this directory - Use `-Dwildfly.zip.path=` to specify explicitly +## httpd Distributions (Native Mode) + +For native httpd testing, you can either use a JBCS ZIP or the system httpd. + +### JBCS ZIP +Place the httpd ZIP and optionally the connectors ZIP here: +- `jbcs-httpd24-httpd-*.zip` — JBCS httpd distribution +- `jbcs-httpd24-webserver-connectors-*.zip` — mod_proxy_cluster modules (auto-detected alongside httpd ZIP) + +```bash +mvn test -Pnative -Dbalancer.type=httpd \ + -Dhttpd.zip.path=distributions/jbcs-httpd24-httpd-2.4.62-RHEL8-x86_64.zip +``` + +Or set explicitly: `-Dhttpd.connectors.zip.path=distributions/jbcs-httpd24-webserver-connectors-*.zip` + +### System httpd +No ZIP needed — use `-Dhttpd.home=/usr` with mod_proxy_cluster modules built from source. +See the main [README.md](../README.md#system-httpd-no-zip-required) for build instructions. + ## .gitignore ZIP files in this directory are ignored by git (they're typically large). diff --git a/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java b/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java index bf8aa57..db4c6c3 100644 --- a/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java +++ b/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java @@ -61,9 +61,12 @@ class NativeHttpdBalancer extends Balancer { private static final int HTTPS_PORT = 8443; private static final int MCMP_PORT = NativePortAllocator.HTTPD_MCMP_PORT; + private static final Path WORK_DIR = Path.of("target", "native-servers", "httpd"); + private Path httpdHome; private Path httpdBinary; private Path confFile; + private Path modulesPath; private NativeProcessManager processManager; private McmpClient mcmpClient; @@ -72,31 +75,16 @@ public void start() { type = BalancerType.HTTPD; try { - Path jbcsZip = findJbcsZip(); - Path extractionRoot = extractJbcsZip(jbcsZip); - extractConnectorsIfAvailable(jbcsZip); - httpdBinary = findHttpdBinary(extractionRoot); - // Derive httpdHome from the binary location (parent of bin/ or sbin/) - httpdHome = httpdBinary.getParent().getParent(); - - runPostinstallIfNeeded(httpdHome); - - confFile = findHttpdConf(httpdHome); - if (confFile == null) { - throw new RuntimeException("httpd.conf not found under " + httpdHome - + " (even after postinstall). Check the JBCS distribution layout."); - } - - patchHttpdConf(); - removeConflictingConfigs(); - Files.createDirectories(confFile.getParent().resolve("extra")); + resolveHttpdInstallation(); + setupConfiguration(); List command = List.of( httpdBinary.toAbsolutePath().toString(), + "-d", serverRoot().toAbsolutePath().toString(), "-f", confFile.toAbsolutePath().toString(), "-DFOREGROUND"); - processManager = new NativeProcessManager("httpd-balancer", command, httpdHome, null); + processManager = new NativeProcessManager("httpd-balancer", command, serverRoot(), null); processManager.start(); mcmpClient = new McmpClient("localhost", MCMP_PORT); @@ -121,6 +109,196 @@ public void start() { } } + /** + * Resolve the httpd installation to use. + * + *

Resolution order: + *

    + *
  1. {@code -Dhttpd.home} — use an existing httpd installation directly
  2. + *
  3. {@code -Dhttpd.zip.path} or auto-discovered ZIP in {@code distributions/} + * — extract and use a JBCS distribution
  4. + *
+ */ + private void resolveHttpdInstallation() throws IOException { + String homeProp = System.getProperty("httpd.home"); + if (homeProp != null && !homeProp.isBlank()) { + httpdHome = Path.of(homeProp); + if (!Files.isDirectory(httpdHome)) { + throw new RuntimeException("httpd.home does not exist: " + homeProp); + } + httpdBinary = findHttpdBinary(httpdHome); + log.info("Using system httpd: {}", httpdBinary); + } else { + Path jbcsZip = findJbcsZip(); + Path extractionRoot = extractJbcsZip(jbcsZip); + extractConnectorsIfAvailable(jbcsZip); + httpdBinary = findHttpdBinary(extractionRoot); + httpdHome = httpdBinary.getParent().getParent(); + runPostinstallIfNeeded(httpdHome); + log.info("Using extracted httpd: {}", httpdHome); + } + + String modulesProp = System.getProperty("httpd.modules.path"); + if (modulesProp != null && !modulesProp.isBlank()) { + modulesPath = Path.of(modulesProp).toAbsolutePath(); + if (!Files.isDirectory(modulesPath)) { + throw new RuntimeException("httpd.modules.path does not exist: " + modulesProp); + } + log.info("Using external modules directory: {}", modulesPath); + } + } + + /** + * Set up the httpd configuration in a working directory. + * + *

For system httpd ({@code -Dhttpd.home}), we create a fresh working directory + * under {@code target/native-servers/httpd/work/} since we cannot write to the + * system config directories. For extracted ZIPs, we patch the config in-place. + */ + private void setupConfiguration() throws IOException { + boolean isSystemHttpd = System.getProperty("httpd.home") != null; + + if (isSystemHttpd) { + setupSystemHttpdWorkDir(); + } else { + confFile = findHttpdConf(httpdHome); + if (confFile == null) { + throw new RuntimeException("httpd.conf not found under " + httpdHome + + " (even after postinstall). Check the JBCS distribution layout."); + } + patchHttpdConf(); + removeConflictingConfigs(); + } + + Files.createDirectories(confFile.getParent().resolve("extra")); + } + + /** + * Create a working directory with a minimal httpd.conf for system httpd. + * This avoids modifying the system configuration in /etc/httpd or /etc/apache2. + */ + private void setupSystemHttpdWorkDir() throws IOException { + Path workDir = WORK_DIR.resolve("work"); + Path confDir = workDir.resolve("conf"); + Path confDDir = workDir.resolve("conf.d"); + Path logsDir = workDir.resolve("logs"); + Path extraDir = confDir.resolve("extra"); + + Files.createDirectories(confDir); + Files.createDirectories(confDDir); + Files.createDirectories(logsDir); + Files.createDirectories(extraDir); + + Path systemModules = resolveSystemModulesDir(); + + // Create modules/ dir with symlinks to system modules and mod_proxy_cluster modules, + // so relative LoadModule paths in conf templates work. + // Always recreated to pick up changes in httpd.modules.path between runs. + Path modulesLink = workDir.resolve("modules"); + if (Files.isDirectory(modulesLink)) { + try (Stream old = Files.list(modulesLink)) { + old.forEach(p -> { try { Files.deleteIfExists(p); } catch (IOException ignored) {} }); + } + } else { + Files.createDirectories(modulesLink); + } + try (Stream stream = Files.list(systemModules)) { + for (Path so : stream.filter(p -> p.toString().endsWith(".so")).toList()) { + Files.createSymbolicLink(modulesLink.resolve(so.getFileName()), so.toAbsolutePath()); + } + } + if (modulesPath != null) { + try (Stream stream = Files.list(modulesPath)) { + for (Path so : stream.filter(p -> p.toString().endsWith(".so")).toList()) { + Path link = modulesLink.resolve(so.getFileName()); + Files.deleteIfExists(link); + Files.createSymbolicLink(link, so.toAbsolutePath()); + } + } + } + + StringBuilder conf = new StringBuilder(); + conf.append("ServerRoot \"").append(workDir.toAbsolutePath()).append("\"\n"); + conf.append("PidFile \"").append(logsDir.toAbsolutePath().resolve("httpd.pid")).append("\"\n"); + conf.append("ErrorLog \"").append(logsDir.toAbsolutePath().resolve("error_log")).append("\"\n"); + conf.append("LogLevel info\n\n"); + + // Load standard modules from system modules dir + for (String module : List.of( + "mpm_event_module:mod_mpm_event.so", + "authz_core_module:mod_authz_core.so", + "unixd_module:mod_unixd.so", + "log_config_module:mod_log_config.so", + "proxy_module:mod_proxy.so", + "proxy_http_module:mod_proxy_http.so", + "proxy_ajp_module:mod_proxy_ajp.so", + "proxy_wstunnel_module:mod_proxy_wstunnel.so", + "slotmem_shm_module:mod_slotmem_shm.so", + "watchdog_module:mod_watchdog.so", + "ssl_module:mod_ssl.so", + "socache_shmcb_module:mod_socache_shmcb.so")) { + String[] parts = module.split(":"); + conf.append("LoadModule ").append(parts[0]).append(" ") + .append(systemModules.toAbsolutePath().resolve(parts[1])).append("\n"); + } + + // Load mod_proxy_cluster modules from the modules path (external or system) + conf.append("\n# mod_proxy_cluster modules\n"); + Path mpcModules = modulesPath != null ? modulesPath : systemModules; + for (String module : List.of( + "manager_module:mod_manager.so", + "proxy_cluster_module:mod_proxy_cluster.so", + "advertise_module:mod_advertise.so")) { + String[] parts = module.split(":"); + Path soFile = mpcModules.resolve(parts[1]); + if (Files.isRegularFile(soFile)) { + conf.append("LoadModule ").append(parts[0]).append(" ") + .append(soFile.toAbsolutePath()).append("\n"); + } + } + // Optional modules + for (String module : List.of( + "lbmethod_cluster_module:mod_lbmethod_cluster.so", + "cluster_slotmem_module:mod_cluster_slotmem.so")) { + String[] parts = module.split(":"); + Path soFile = mpcModules.resolve(parts[1]); + if (Files.isRegularFile(soFile)) { + conf.append("LoadModule ").append(parts[0]).append(" ") + .append(soFile.toAbsolutePath()).append("\n"); + } + } + + conf.append("\n#Listen 80\n"); + conf.append("Listen 8080\n\n"); + + // MCMP and VirtualHost config comes from conf.d/mod_proxy_cluster.conf + conf.append("IncludeOptional conf/extra/ssl-*.conf\n"); + conf.append("IncludeOptional conf.d/*.conf\n"); + + confFile = confDir.resolve("httpd.conf"); + Files.writeString(confFile, conf.toString()); + log.info("Generated httpd.conf at {}", confFile); + + // Copy mod_proxy_cluster.conf template to conf.d/ + copyModProxyClusterConf(); + } + + /** + * Find the system httpd modules directory. + * Checks common locations for Fedora/RHEL and Debian/Ubuntu. + */ + private Path resolveSystemModulesDir() { + for (String candidate : List.of( + "lib64/httpd/modules", + "lib/apache2/modules", + "modules")) { + Path dir = httpdHome.resolve(candidate); + if (Files.isDirectory(dir)) return dir; + } + throw new RuntimeException("Cannot find httpd modules directory under " + httpdHome + + ". Set -Dhttpd.modules.path to specify the location."); + } + @Override public void stop() { if (processManager != null) { @@ -174,7 +352,7 @@ public int getManagementPort() { @Override public String getServerHome() { - return requireHttpdHome().toAbsolutePath().toString(); + return serverRoot().toAbsolutePath().toString(); } @Override @@ -184,7 +362,7 @@ public String getConfDir() { @Override public String getModProxyClusterConfPath() { - return requireHttpdHome().resolve("conf.d").resolve("mod_proxy_cluster.conf") + return serverRoot().resolve("conf.d").resolve("mod_proxy_cluster.conf") .toAbsolutePath().toString(); } @@ -207,16 +385,15 @@ public int getMcmpSslPort() { @Override public CommandResult execCommand(String... command) throws Exception { - return NativeProcessManager.execCommand(requireHttpdHome(), command); + return NativeProcessManager.execCommand(serverRoot(), command); } @Override public void copyClasspathResource(String classpathResource, String destPath) { - requireHttpdHome(); try { Path dest = Path.of(destPath); if (!dest.isAbsolute()) { - dest = httpdHome.resolve(destPath); + dest = serverRoot().resolve(destPath); } Files.createDirectories(dest.getParent()); @@ -235,11 +412,10 @@ public void copyClasspathResource(String classpathResource, String destPath) { @Override public void copyLocalFile(Path hostPath, String destPath) { - requireHttpdHome(); try { Path dest = Path.of(destPath); if (!dest.isAbsolute()) { - dest = httpdHome.resolve(destPath); + dest = serverRoot().resolve(destPath); } Files.createDirectories(dest.getParent()); Files.copy(hostPath, dest, StandardCopyOption.REPLACE_EXISTING); @@ -396,18 +572,20 @@ public void setMaxRetries(int maxRetries) throws Exception { @Override public void reload() throws Exception { log.info("Reloading httpd balancer (graceful restart)"); + Path conf = requireConfFile(); + String serverRootStr = serverRoot().toAbsolutePath().toString(); if (TestMode.isWindows()) { processManager.stop(); - Path conf = requireConfFile(); List command = List.of( httpdBinary.toAbsolutePath().toString(), + "-d", serverRootStr, "-f", conf.toAbsolutePath().toString(), "-DFOREGROUND"); - processManager = new NativeProcessManager("httpd-balancer", command, httpdHome, null); + processManager = new NativeProcessManager("httpd-balancer", command, serverRoot(), null); processManager.start(); } else { - Path conf = requireConfFile(); CommandResult result = execCommand(httpdBinary.toAbsolutePath().toString(), + "-d", serverRootStr, "-f", conf.toAbsolutePath().toString(), "-k", "graceful"); if (!result.isSuccess()) { log.warn("httpd graceful restart returned exit code {}: {}", @@ -440,6 +618,14 @@ private Path requireConfFile() { return confFile; } + /** + * The httpd server root — the directory containing conf/, conf.d/, logs/, etc. + * For extracted ZIPs this is httpdHome. For system httpd this is the work directory. + */ + private Path serverRoot() { + return requireConfFile().getParent().getParent(); + } + /** * Find a JBCS httpd distribution ZIP in the {@code distributions/} directory. * @@ -543,7 +729,8 @@ private Path findHttpdBinary(Path home) { private static final List HTTPD_BINARY_SEARCH_PATHS = TestMode.isWindows() ? List.of("bin/httpd.exe", "sbin/httpd.exe", "httpd/bin/httpd.exe", "httpd/sbin/httpd.exe") - : List.of("sbin/httpd", "bin/httpd", "httpd/sbin/httpd", "httpd/bin/httpd"); + : List.of("sbin/httpd", "bin/httpd", "httpd/sbin/httpd", "httpd/bin/httpd", + "sbin/apache2", "bin/apache2"); private Path findHttpdBinaryOrNull(Path home) { for (String candidate : HTTPD_BINARY_SEARCH_PATHS) { @@ -656,7 +843,7 @@ private void patchHttpdConf() throws IOException { * mod_proxy_balancer conflicts with mod_proxy_cluster and must not be loaded. */ private void disableProxyBalancerInFragments() throws IOException { - Path confModulesD = httpdHome.resolve("conf.modules.d"); + Path confModulesD = serverRoot().resolve("conf.modules.d"); if (!Files.isDirectory(confModulesD)) return; try (Stream stream = Files.list(confModulesD)) { @@ -677,7 +864,7 @@ private void disableProxyBalancerInFragments() throws IOException { * Copy mod_proxy_cluster.conf from classpath to httpd conf.d/. */ private void copyModProxyClusterConf() throws IOException { - Path destDir = httpdHome.resolve("conf.d"); + Path destDir = serverRoot().resolve("conf.d"); Files.createDirectories(destDir); Path dest = destDir.resolve("mod_proxy_cluster.conf"); @@ -782,7 +969,7 @@ private void extractOverlayZip(Path zipPath, Path targetDir) throws IOException * the connectors' version; this method handles the separate native config file. */ private void removeConflictingConfigs() throws IOException { - Path confD = httpdHome.resolve("conf.d"); + Path confD = serverRoot().resolve("conf.d"); if (!Files.isDirectory(confD)) return; Path modClusterNative = confD.resolve("mod_cluster-native.conf"); @@ -823,14 +1010,16 @@ private void logHttpdDiagnostics() { } if (httpdHome != null) { - Path modulesDir = httpdHome.resolve("modules"); + Path mDir = modulesPath != null ? modulesPath : httpdHome.resolve("modules"); for (String module : List.of("mod_manager.so", "mod_proxy_cluster.so", "mod_advertise.so", "mod_lbmethod_cluster.so")) { - Path p = modulesDir.resolve(module); + Path p = mDir.resolve(module); log.error(" {} -> {}", module, Files.isRegularFile(p) ? "PRESENT" : "MISSING"); } - Path errorLog = httpdHome.resolve("logs/error_log"); + Path errorLog = confFile != null + ? serverRoot().resolve("logs/error_log") + : httpdHome.resolve("logs/error_log"); if (Files.isRegularFile(errorLog)) { try { String errors = Files.readString(errorLog); From e2f5429b69dc7dcda734f57a4e108026bb064e3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Ka=C5=A1=C3=ADk?= Date: Mon, 1 Jun 2026 10:54:32 +0200 Subject: [PATCH 5/7] Wrap LoadModule directives with IfModule guards Ubuntu's apache2 has some modules (e.g. unixd_module) compiled in as built-in. Attempting to LoadModule a built-in module fails with a syntax error. Wrap all LoadModule directives in the generated httpd.conf with guards to skip modules that are already loaded or built-in. --- .../test/utils/balancer/NativeHttpdBalancer.java | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java b/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java index db4c6c3..af74705 100644 --- a/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java +++ b/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java @@ -223,7 +223,7 @@ private void setupSystemHttpdWorkDir() throws IOException { conf.append("ErrorLog \"").append(logsDir.toAbsolutePath().resolve("error_log")).append("\"\n"); conf.append("LogLevel info\n\n"); - // Load standard modules from system modules dir + // Load standard modules from system modules dir (IfModule guards handle built-in modules) for (String module : List.of( "mpm_event_module:mod_mpm_event.so", "authz_core_module:mod_authz_core.so", @@ -238,8 +238,10 @@ private void setupSystemHttpdWorkDir() throws IOException { "ssl_module:mod_ssl.so", "socache_shmcb_module:mod_socache_shmcb.so")) { String[] parts = module.split(":"); - conf.append("LoadModule ").append(parts[0]).append(" ") - .append(systemModules.toAbsolutePath().resolve(parts[1])).append("\n"); + Path soFile = systemModules.toAbsolutePath().resolve(parts[1]); + conf.append("\n"); + conf.append(" LoadModule ").append(parts[0]).append(" ").append(soFile).append("\n"); + conf.append("\n"); } // Load mod_proxy_cluster modules from the modules path (external or system) @@ -252,8 +254,10 @@ private void setupSystemHttpdWorkDir() throws IOException { String[] parts = module.split(":"); Path soFile = mpcModules.resolve(parts[1]); if (Files.isRegularFile(soFile)) { - conf.append("LoadModule ").append(parts[0]).append(" ") + conf.append("\n"); + conf.append(" LoadModule ").append(parts[0]).append(" ") .append(soFile.toAbsolutePath()).append("\n"); + conf.append("\n"); } } // Optional modules @@ -263,8 +267,10 @@ private void setupSystemHttpdWorkDir() throws IOException { String[] parts = module.split(":"); Path soFile = mpcModules.resolve(parts[1]); if (Files.isRegularFile(soFile)) { - conf.append("LoadModule ").append(parts[0]).append(" ") + conf.append("\n"); + conf.append(" LoadModule ").append(parts[0]).append(" ") .append(soFile.toAbsolutePath()).append("\n"); + conf.append("\n"); } } From be37d72830f45a5f532d774008e2c9b114fc9c7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Ka=C5=A1=C3=ADk?= Date: Mon, 1 Jun 2026 11:05:19 +0200 Subject: [PATCH 6/7] Clean work dir on each start to remove stale SSL configs When multiple test classes run sequentially (e.g. SslFailoverTest after SslCrlTest), stale ssl-data.conf files from the previous test remain in conf/extra/ and cause "Cannot define multiple Listeners" errors on the next httpd start. Delete and recreate the entire work directory at the beginning of each setupSystemHttpdWorkDir() call. --- .../utils/balancer/NativeHttpdBalancer.java | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java b/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java index af74705..a53cb00 100644 --- a/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java +++ b/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java @@ -179,6 +179,15 @@ private void setupConfiguration() throws IOException { */ private void setupSystemHttpdWorkDir() throws IOException { Path workDir = WORK_DIR.resolve("work"); + + // Clean previous work dir to remove stale SSL configs from prior test classes + if (Files.isDirectory(workDir)) { + try (Stream walk = Files.walk(workDir)) { + walk.sorted(java.util.Comparator.reverseOrder()) + .forEach(p -> { try { Files.deleteIfExists(p); } catch (IOException ignored) {} }); + } + } + Path confDir = workDir.resolve("conf"); Path confDDir = workDir.resolve("conf.d"); Path logsDir = workDir.resolve("logs"); @@ -193,15 +202,8 @@ private void setupSystemHttpdWorkDir() throws IOException { // Create modules/ dir with symlinks to system modules and mod_proxy_cluster modules, // so relative LoadModule paths in conf templates work. - // Always recreated to pick up changes in httpd.modules.path between runs. Path modulesLink = workDir.resolve("modules"); - if (Files.isDirectory(modulesLink)) { - try (Stream old = Files.list(modulesLink)) { - old.forEach(p -> { try { Files.deleteIfExists(p); } catch (IOException ignored) {} }); - } - } else { - Files.createDirectories(modulesLink); - } + Files.createDirectories(modulesLink); try (Stream stream = Files.list(systemModules)) { for (Path so : stream.filter(p -> p.toString().endsWith(".so")).toList()) { Files.createSymbolicLink(modulesLink.resolve(so.getFileName()), so.toAbsolutePath()); @@ -211,7 +213,7 @@ private void setupSystemHttpdWorkDir() throws IOException { try (Stream stream = Files.list(modulesPath)) { for (Path so : stream.filter(p -> p.toString().endsWith(".so")).toList()) { Path link = modulesLink.resolve(so.getFileName()); - Files.deleteIfExists(link); + Files.deleteIfExists(link); // override system module with mod_proxy_cluster version Files.createSymbolicLink(link, so.toAbsolutePath()); } } From 75d28dd84e0b06353275fde2c151bd855b9c4573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Ka=C5=A1=C3=ADk?= Date: Mon, 1 Jun 2026 11:45:24 +0200 Subject: [PATCH 7/7] Fix duplicate SSL include and clean work dir between tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove IncludeOptional conf/extra/ssl-*.conf from the generated httpd.conf — the mod_proxy_cluster.conf template already includes it, causing ssl-data.conf to be parsed twice and triggering "Cannot define multiple Listeners on the same IP:port" on graceful restart. Also clean the entire work directory on each start to prevent stale SSL configs from prior test classes from persisting. --- .../modcluster/test/utils/balancer/NativeHttpdBalancer.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java b/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java index a53cb00..2e22539 100644 --- a/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java +++ b/src/test/java/org/jboss/modcluster/test/utils/balancer/NativeHttpdBalancer.java @@ -279,8 +279,7 @@ private void setupSystemHttpdWorkDir() throws IOException { conf.append("\n#Listen 80\n"); conf.append("Listen 8080\n\n"); - // MCMP and VirtualHost config comes from conf.d/mod_proxy_cluster.conf - conf.append("IncludeOptional conf/extra/ssl-*.conf\n"); + // MCMP, VirtualHost, and SSL includes come from conf.d/mod_proxy_cluster.conf conf.append("IncludeOptional conf.d/*.conf\n"); confFile = confDir.resolve("httpd.conf");