From 39926e2e97264a88b1d3fad14d52b3b3fc6b092a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Ka=C5=A1=C3=ADk?=
Date: Tue, 2 Jun 2026 14:20:15 +0200
Subject: [PATCH 1/6] Add AJP REMOTE_USER authentication propagation test
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Validates the end-to-end path that IIS/isapi_redirect uses after
Windows authentication: httpd authenticates the user (via Basic auth
as a stand-in for Windows auth) → mod_proxy_ajp forwards REMOTE_USER
as an AJP attribute → Undertow receives it → Elytron's EXTERNAL
mechanism authenticates the user → the secured servlet is accessible.
Three test scenarios: authenticated user with correct role (200),
no authentication (403), authenticated user with wrong role (403).
Adds -Dhttpd.skip.mod_proxy_cluster flag to run httpd without
mod_proxy_cluster modules, since its global proxy handler intercepts
all ProxyPass requests. The test sets this flag via @BeforeAll.
---
.github/workflows/ci.yml | 2 +-
README.md | 10 +-
.../test/apps/SecuredAppBuilder.java | 39 ++++
.../modcluster/test/apps/SecuredServlet.java | 28 +++
.../test/auth/AjpAuthConfigurator.java | 168 ++++++++++++++++
.../test/auth/AjpAuthPropagationTest.java | 189 ++++++++++++++++++
.../utils/balancer/NativeHttpdBalancer.java | 144 ++++++++-----
src/test/resources/apps/secured/jboss-web.xml | 4 +
src/test/resources/apps/secured/web.xml | 28 +++
.../resources/httpd/mod_proxy_cluster.conf | 5 +-
.../httpd/mod_proxy_cluster_ssl.conf | 2 +-
11 files changed, 565 insertions(+), 54 deletions(-)
create mode 100644 src/test/java/org/jboss/modcluster/test/apps/SecuredAppBuilder.java
create mode 100644 src/test/java/org/jboss/modcluster/test/apps/SecuredServlet.java
create mode 100644 src/test/java/org/jboss/modcluster/test/auth/AjpAuthConfigurator.java
create mode 100644 src/test/java/org/jboss/modcluster/test/auth/AjpAuthPropagationTest.java
create mode 100644 src/test/resources/apps/secured/jboss-web.xml
create mode 100644 src/test/resources/apps/secured/web.xml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cddd569..86931f7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -100,7 +100,7 @@ jobs:
- name: Run tests
run: |
mvn -B test -Pnative \
- -Dtest=${{ env.TEST_CLASS }} \
+ -Dtest=${{ env.TEST_CLASS }},AjpAuthPropagationTest \
-DexcludedGroups=none \
-Dbalancer.type=httpd \
-Dhttpd.home=/usr \
diff --git a/README.md b/README.md
index 456c5fe..49f7ef5 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,9 @@ src/test/java/org/jboss/modcluster/test/
│ └── LoadMetricsTest.java
├── session/ # Session management tests
│ └── SessionManagementTest.java
+├── auth/ # Authentication propagation tests
+│ ├── AjpAuthConfigurator.java
+│ └── AjpAuthPropagationTest.java
├── ssl/ # SSL/TLS tests
│ ├── SslCrlTest.java
│ ├── SslFailoverTest.java
@@ -289,6 +292,9 @@ String result = worker.executeCli("/subsystem=modcluster:read-resource");
- **SslFailoverTest** - SSL with failover scenarios
- **SslWorkerAuthenticationTest** - Mutual SSL authentication
+### Authentication Tests
+- **AjpAuthPropagationTest** - REMOTE_USER propagation via AJP (Elytron EXTERNAL mechanism)
+
### Load Balancing Tests
- **LoadBalancingGroupFailoverTest** - Load distribution and group failover
- **LoadMetricsTest** - Load metrics calculation and custom metrics
@@ -325,6 +331,7 @@ This test suite aims for feature parity with `noe-tests/modcluster` (64 test fil
| Advanced Failover | Yes | AdvancedFailoverTest, FailoverSettingsTest |
| Load Balancing | Yes | LoadBalancingGroupFailoverTest, LoadMetricsTest |
| SSL/TLS | Yes | SslCrlTest, SslFailoverTest, SslWorkerAuthenticationTest |
+| Authentication (AJP) | Yes | AjpAuthPropagationTest |
| Dynamic Reconfiguration | Yes | DynamicReconfTest, SettingsTest |
| Context Lifecycle | Yes | ContextLifecycleTest |
| Session Management | Yes | SessionManagementTest |
@@ -338,7 +345,7 @@ This test suite aims for feature parity with `noe-tests/modcluster` (64 test fil
| Area | noe-tests Reference |
|------|-------------------|
-| AJP Protocol | ModClusterAJP.groovy |
+| AJP Protocol (beyond auth) | ModClusterAJP.groovy |
| mod_proxy / mod_rewrite | ModProxyTest.groovy, ModRewriteTest.groovy |
| Bug-specific regressions | JBCS*, JBQA* test files |
@@ -421,6 +428,7 @@ In practice, always provide a WildFly/EAP ZIP — the fallback images are not pu
| `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.skip.mod_proxy_cluster` | Native | `false` | Skip mod_proxy_cluster modules (for direct AJP proxy tests) |
| `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 |
diff --git a/src/test/java/org/jboss/modcluster/test/apps/SecuredAppBuilder.java b/src/test/java/org/jboss/modcluster/test/apps/SecuredAppBuilder.java
new file mode 100644
index 0000000..ed2ab5b
--- /dev/null
+++ b/src/test/java/org/jboss/modcluster/test/apps/SecuredAppBuilder.java
@@ -0,0 +1,39 @@
+package org.jboss.modcluster.test.apps;
+
+import org.jboss.shrinkwrap.api.ShrinkWrap;
+import org.jboss.shrinkwrap.api.exporter.ZipExporter;
+import org.jboss.shrinkwrap.api.spec.WebArchive;
+
+import java.io.File;
+import java.net.URL;
+
+/**
+ * Builder for creating the secured WAR application at runtime using ShrinkWrap.
+ * Packages {@link SecuredServlet} with a {@code web.xml} that declares the EXTERNAL
+ * auth method and a {@code jboss-web.xml} that maps to the {@code ajp-auth-domain}
+ * application security domain.
+ */
+public class SecuredAppBuilder {
+
+ /**
+ * Creates the secured.war file.
+ *
+ * @return File reference to generated WAR in temp directory
+ */
+ public static File createSecuredApp() {
+ ClassLoader cl = Thread.currentThread().getContextClassLoader();
+ URL webXml = cl.getResource("apps/secured/web.xml");
+ URL jbossWebXml = cl.getResource("apps/secured/jboss-web.xml");
+
+ final WebArchive war = ShrinkWrap.create(WebArchive.class, "secured.war")
+ .addClass(SecuredServlet.class)
+ .setWebXML(webXml)
+ .addAsWebInfResource(jbossWebXml, "jboss-web.xml");
+
+ final File tempWar = new File(System.getProperty("java.io.tmpdir"), "secured.war");
+ war.as(ZipExporter.class).exportTo(tempWar, true);
+ tempWar.deleteOnExit();
+
+ return tempWar;
+ }
+}
diff --git a/src/test/java/org/jboss/modcluster/test/apps/SecuredServlet.java b/src/test/java/org/jboss/modcluster/test/apps/SecuredServlet.java
new file mode 100644
index 0000000..cd706e9
--- /dev/null
+++ b/src/test/java/org/jboss/modcluster/test/apps/SecuredServlet.java
@@ -0,0 +1,28 @@
+package org.jboss.modcluster.test.apps;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.annotation.WebServlet;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+
+/**
+ * Servlet secured by the EXTERNAL authentication mechanism.
+ * Returns the authenticated user's name and the worker node name.
+ * Security constraints are declared in {@code web.xml} with
+ * {@code EXTERNAL}.
+ */
+@WebServlet("/secured")
+public class SecuredServlet extends HttpServlet {
+
+ @Override
+ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+ resp.setContentType("text/plain");
+ PrintWriter out = resp.getWriter();
+ out.println("user=" + req.getRemoteUser());
+ out.println("worker=" + System.getProperty("jboss.node.name", "unknown"));
+ }
+}
diff --git a/src/test/java/org/jboss/modcluster/test/auth/AjpAuthConfigurator.java b/src/test/java/org/jboss/modcluster/test/auth/AjpAuthConfigurator.java
new file mode 100644
index 0000000..c26c1c1
--- /dev/null
+++ b/src/test/java/org/jboss/modcluster/test/auth/AjpAuthConfigurator.java
@@ -0,0 +1,168 @@
+package org.jboss.modcluster.test.auth;
+
+import org.jboss.dmr.ModelNode;
+import org.jboss.modcluster.test.utils.WildFlyWorker;
+import org.jboss.modcluster.test.utils.balancer.Balancer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.wildfly.extras.creaper.core.online.ModelNodeResult;
+import org.wildfly.extras.creaper.core.online.operations.Address;
+import org.wildfly.extras.creaper.core.online.operations.Operations;
+import org.wildfly.extras.creaper.core.online.operations.Values;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * Configures Elytron EXTERNAL mechanism authentication on WildFly workers
+ * and httpd REMOTE_USER injection on the balancer.
+ *
+ * This enables end-to-end testing of AJP authentication propagation:
+ * httpd authenticates the user via {@code mod_auth_basic} → sets {@code REMOTE_USER}
+ * → {@code mod_proxy_ajp} forwards it as the AJP REMOTE_USER attribute → Undertow
+ * receives it → Elytron's EXTERNAL mechanism authenticates the user.
+ */
+public class AjpAuthConfigurator {
+
+ private static final Logger log = LoggerFactory.getLogger(AjpAuthConfigurator.class);
+
+ private static final String REALM_NAME = "ajp-auth-realm";
+ private static final String ROLE_DECODER_NAME = "ajp-role-decoder";
+ private static final String SECURITY_DOMAIN_NAME = "ajp-auth-sd";
+ private static final String AUTH_FACTORY_NAME = "ajp-auth-factory";
+ private static final String APP_SECURITY_DOMAIN = "ajp-auth-domain";
+
+ /**
+ * Configure Elytron on a worker for EXTERNAL mechanism authentication.
+ * Creates a filesystem-realm with the given users, wires up the security domain
+ * and http-authentication-factory, and links it to Undertow.
+ *
+ * @param worker the WildFly worker to configure
+ * @param users user entries (username → role)
+ */
+ public void configureWorker(WildFlyWorker worker, UserEntry... users) throws Exception {
+ Operations ops = worker.getOperations();
+
+ Address realmAddr = Address.subsystem("elytron").and("filesystem-realm", REALM_NAME);
+ if (!ops.exists(realmAddr)) {
+ ops.add(realmAddr, Values.of("path", "ajp-auth-users")
+ .and("relative-to", "jboss.server.config.dir")).assertSuccess();
+ }
+
+ for (UserEntry user : users) {
+ ModelNodeResult result = ops.invoke("add-identity", realmAddr,
+ Values.of("identity", user.username));
+ if (result.isSuccess()) {
+ ops.invoke("add-identity-attribute", realmAddr,
+ Values.of("identity", user.username)
+ .and("name", "Roles")
+ .andList("value", user.role)).assertSuccess();
+ log.info("Added user '{}' with role '{}' to realm", user.username, user.role);
+ } else {
+ log.info("User '{}' already exists in realm, skipping", user.username);
+ }
+ }
+
+ Address decoderAddr = Address.subsystem("elytron").and("simple-role-decoder", ROLE_DECODER_NAME);
+ if (!ops.exists(decoderAddr)) {
+ ops.add(decoderAddr, Values.of("attribute", "Roles")).assertSuccess();
+ }
+
+ Address domainAddr = Address.subsystem("elytron").and("security-domain", SECURITY_DOMAIN_NAME);
+ if (!ops.exists(domainAddr)) {
+ ModelNode realmEntry = new ModelNode();
+ realmEntry.get("realm").set(REALM_NAME);
+ realmEntry.get("role-decoder").set(ROLE_DECODER_NAME);
+
+ ops.add(domainAddr, Values.of("default-realm", REALM_NAME)
+ .and("permission-mapper", "default-permission-mapper")
+ .andList("realms", realmEntry)).assertSuccess();
+ }
+
+ Address factoryAddr = Address.subsystem("elytron")
+ .and("http-authentication-factory", AUTH_FACTORY_NAME);
+ if (!ops.exists(factoryAddr)) {
+ ModelNode mechanismEntry = new ModelNode();
+ mechanismEntry.get("mechanism-name").set("EXTERNAL");
+
+ ops.add(factoryAddr, Values.of("security-domain", SECURITY_DOMAIN_NAME)
+ .and("http-server-mechanism-factory", "global")
+ .andList("mechanism-configurations", mechanismEntry)).assertSuccess();
+ }
+
+ Address appSecDomain = Address.subsystem("undertow")
+ .and("application-security-domain", APP_SECURITY_DOMAIN);
+ if (!ops.exists(appSecDomain)) {
+ ops.add(appSecDomain, Values.of("http-authentication-factory", AUTH_FACTORY_NAME)).assertSuccess();
+ }
+
+ worker.reload();
+ log.info("Elytron EXTERNAL mechanism configured on worker '{}'", worker.getName());
+ }
+
+ /**
+ * Configure httpd to proxy requests to the secured app via mod_proxy_ajp
+ * with REMOTE_USER set via Basic authentication. This uses a direct
+ * {@code ProxyPass} to the worker's AJP port.
+ *
+ * When {@code username} is non-null, httpd Basic auth is configured with
+ * an htpasswd file. After authenticating the user, httpd sets the AJP protocol's
+ * {@code remote_user} attribute, which Undertow's AJP listener forwards to
+ * Elytron's EXTERNAL mechanism. This is the same AJP attribute path that
+ * IIS/isapi_redirect uses after Windows authentication.
+ *
+ * @param balancer the httpd balancer
+ * @param username the username to inject as REMOTE_USER
+ * @param ajpPort the worker's AJP listener port
+ */
+ public void configureBalancerRemoteUser(Balancer balancer, String username, int ajpPort)
+ throws Exception {
+ StringBuilder conf = new StringBuilder();
+ conf.append("\n");
+ conf.append(" LoadModule authn_file_module modules/mod_authn_file.so\n");
+ conf.append("\n");
+ conf.append("\n");
+ conf.append(" LoadModule authn_core_module modules/mod_authn_core.so\n");
+ conf.append("\n");
+ conf.append("\n");
+ conf.append(" LoadModule authz_user_module modules/mod_authz_user.so\n");
+ conf.append("\n");
+ conf.append("\n");
+ conf.append(" LoadModule auth_basic_module modules/mod_auth_basic.so\n");
+ conf.append("\n\n");
+
+ conf.append("ProxyPass /secured/ ajp://localhost:").append(ajpPort).append("/secured/\n");
+ conf.append("ProxyPassReverse /secured/ ajp://localhost:").append(ajpPort).append("/secured/\n\n");
+
+ if (username != null) {
+ String htpasswdPath = balancer.getServerHome() + "/conf/test-users.htpasswd";
+ conf.append("\n");
+ conf.append(" AuthType Basic\n");
+ conf.append(" AuthName \"Test\"\n");
+ conf.append(" AuthBasicProvider file\n");
+ conf.append(" AuthUserFile \"").append(htpasswdPath).append("\"\n");
+ conf.append(" Require valid-user\n");
+ conf.append("\n");
+
+ balancer.execCommand("htpasswd", "-cb", htpasswdPath, username, "password");
+ }
+
+ Path tempConf = Files.createTempFile("ajp-auth", ".conf");
+ Files.writeString(tempConf, conf.toString());
+ balancer.copyLocalFile(tempConf, balancer.getConfDir() + "/extra/ajp-auth.conf");
+
+ balancer.reload();
+ log.info("Configured direct AJP proxy to port {} with REMOTE_USER='{}'", ajpPort, username);
+ }
+
+ /** A username-to-role mapping for the Elytron filesystem realm. */
+ public static class UserEntry {
+ final String username;
+ final String role;
+
+ public UserEntry(String username, String role) {
+ this.username = username;
+ this.role = role;
+ }
+ }
+}
diff --git a/src/test/java/org/jboss/modcluster/test/auth/AjpAuthPropagationTest.java b/src/test/java/org/jboss/modcluster/test/auth/AjpAuthPropagationTest.java
new file mode 100644
index 0000000..52d67b7
--- /dev/null
+++ b/src/test/java/org/jboss/modcluster/test/auth/AjpAuthPropagationTest.java
@@ -0,0 +1,189 @@
+package org.jboss.modcluster.test.auth;
+
+import org.jboss.modcluster.test.apps.SecuredAppBuilder;
+import org.jboss.modcluster.test.base.ModClusterTestExtension;
+import org.jboss.modcluster.test.base.ModClusterTestExtension.TestCluster;
+import org.jboss.modcluster.test.utils.HttpClient;
+import org.jboss.modcluster.test.utils.HttpClient.HttpResponse;
+import org.jboss.modcluster.test.utils.TestTimeouts;
+import org.jboss.modcluster.test.utils.WildFlyWorker;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.wildfly.extras.creaper.core.online.operations.Address;
+import org.wildfly.extras.creaper.core.online.operations.Operations;
+
+import java.io.File;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+
+import static java.time.Duration.ofSeconds;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * Tests REMOTE_USER authentication propagation via AJP from httpd to WildFly/Elytron.
+ *
+ * Validates the end-to-end path: httpd authenticates the user via Basic auth
+ * → sets {@code REMOTE_USER} → {@code mod_proxy_ajp} forwards it as the AJP
+ * {@code remote_user} attribute → Undertow receives it → Elytron's EXTERNAL mechanism
+ * authenticates the user → the secured servlet is accessible.
+ *
+ * Uses a direct {@code ProxyPass ajp://} to the worker's AJP port, which is the same
+ * protocol path used by IIS/isapi_redirect after Windows authentication.
+ */
+@Tag("native")
+@ExtendWith(ModClusterTestExtension.class)
+public class AjpAuthPropagationTest {
+
+ private static final Logger log = LoggerFactory.getLogger(AjpAuthPropagationTest.class);
+
+ @BeforeAll
+ static void disableModProxyCluster() {
+ System.setProperty("httpd.skip.mod_proxy_cluster", "true");
+ }
+
+ @AfterAll
+ static void restoreModProxyCluster() {
+ System.clearProperty("httpd.skip.mod_proxy_cluster");
+ }
+
+ private static final int AJP_BASE_PORT = 8019;
+ private static final int AJP_PORT = 8119; // base + worker1 offset (100)
+ private static final String AJP_SOCKET_BINDING = "ajp-test";
+ private static final String AJP_LISTENER = "ajp-test-listener";
+
+ /**
+ * Verifies that a user with a valid REMOTE_USER and the correct Elytron role
+ * can access a secured servlet through the balancer via AJP.
+ */
+ @Test
+ public void testAuthenticatedUserCanAccessSecuredServlet(final TestCluster cluster,
+ final HttpClient httpClient) throws Exception {
+ AjpAuthConfigurator configurator = new AjpAuthConfigurator();
+
+ configurator.configureBalancerRemoteUser(cluster.getBalancer(), "testuser", AJP_PORT);
+
+ cluster.startWorkers(1);
+ WildFlyWorker worker = cluster.getWorker1();
+
+ configurator.configureWorker(worker,
+ new AjpAuthConfigurator.UserEntry("testuser", "gooduser"));
+ addAjpListener(worker);
+
+ File securedWar = SecuredAppBuilder.createSecuredApp();
+ worker.deployment().deploy(securedWar);
+
+ String url = cluster.getBalancer().getHttpUrl() + "/secured/secured";
+ awaitAjpAvailable(httpClient, url);
+
+ HttpResponse response = httpClient.get(url, basicAuthHeaders("testuser", "password"));
+
+ log.info("Response: status={}, body={}", response.getStatusCode(), response.getBody());
+ assertThat(response.getStatusCode()).isEqualTo(200);
+ assertThat(response.getBody()).contains("user=testuser");
+ }
+
+ /**
+ * Verifies that a request without REMOTE_USER results in the EXTERNAL mechanism
+ * having no principal, and the secured servlet rejects the request with 403.
+ */
+ @Test
+ public void testNoRemoteUserIsRejected(final TestCluster cluster,
+ final HttpClient httpClient) throws Exception {
+ AjpAuthConfigurator configurator = new AjpAuthConfigurator();
+
+ // ProxyPass without Basic auth — no REMOTE_USER in AJP
+ configurator.configureBalancerRemoteUser(cluster.getBalancer(), null, AJP_PORT);
+
+ cluster.startWorkers(1);
+ WildFlyWorker worker = cluster.getWorker1();
+
+ configurator.configureWorker(worker,
+ new AjpAuthConfigurator.UserEntry("testuser", "gooduser"));
+ addAjpListener(worker);
+
+ File securedWar = SecuredAppBuilder.createSecuredApp();
+ worker.deployment().deploy(securedWar);
+
+ String url = cluster.getBalancer().getHttpUrl() + "/secured/secured";
+ awaitAjpAvailable(httpClient, url);
+
+ HttpResponse response = httpClient.get(url);
+
+ log.info("Response (no REMOTE_USER): status={}", response.getStatusCode());
+ assertThat(response.getStatusCode()).isEqualTo(403);
+ }
+
+ /**
+ * Verifies that a user who exists in the Elytron realm but does not have
+ * the required role is rejected with 403.
+ */
+ @Test
+ public void testUnauthorizedUserIsRejected(final TestCluster cluster,
+ final HttpClient httpClient) throws Exception {
+ AjpAuthConfigurator configurator = new AjpAuthConfigurator();
+
+ configurator.configureBalancerRemoteUser(cluster.getBalancer(), "baduser", AJP_PORT);
+
+ cluster.startWorkers(1);
+ WildFlyWorker worker = cluster.getWorker1();
+
+ configurator.configureWorker(worker,
+ new AjpAuthConfigurator.UserEntry("testuser", "gooduser"),
+ new AjpAuthConfigurator.UserEntry("baduser", "badrole"));
+ addAjpListener(worker);
+
+ File securedWar = SecuredAppBuilder.createSecuredApp();
+ worker.deployment().deploy(securedWar);
+
+ String url = cluster.getBalancer().getHttpUrl() + "/secured/secured";
+ awaitAjpAvailable(httpClient, url);
+
+ HttpResponse response = httpClient.get(url, basicAuthHeaders("baduser", "password"));
+
+ log.info("Response (wrong role): status={}", response.getStatusCode());
+ assertThat(response.getStatusCode()).isEqualTo(403);
+ }
+
+ private void addAjpListener(WildFlyWorker worker) throws Exception {
+ Operations ops = worker.getOperations();
+ Address sbAddr = Address.of("socket-binding-group", "standard-sockets")
+ .and("socket-binding", AJP_SOCKET_BINDING);
+ if (!ops.exists(sbAddr)) {
+ worker.undertow().addSocketBinding(AJP_SOCKET_BINDING, AJP_BASE_PORT);
+ }
+ Address listenerAddr = Address.subsystem("undertow")
+ .and("server", "default-server")
+ .and("ajp-listener", AJP_LISTENER);
+ if (!ops.exists(listenerAddr)) {
+ worker.undertow().addAjpListener(AJP_LISTENER, "default-server", AJP_SOCKET_BINDING);
+ worker.reload();
+ }
+ log.info("AJP listener on port {} ready", AJP_PORT);
+ }
+
+ private void awaitAjpAvailable(HttpClient httpClient, String url) {
+ await().atMost(TestTimeouts.CLUSTER_FORMATION)
+ .pollInterval(ofSeconds(2))
+ .ignoreExceptions()
+ .untilAsserted(() -> {
+ HttpResponse response = httpClient.get(url);
+ assertThat(response.getStatusCode()).isLessThan(500);
+ });
+ log.info("AJP proxy responding at {}", url);
+ }
+
+ private static Map basicAuthHeaders(String username, String password) {
+ String credentials = Base64.getEncoder().encodeToString(
+ (username + ":" + password).getBytes());
+ Map headers = new HashMap<>();
+ headers.put("Authorization", "Basic " + credentials);
+ return headers;
+ }
+}
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 2e22539..024e238 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
@@ -26,6 +26,8 @@
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
+import java.net.HttpURLConnection;
+import java.net.URL;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
@@ -87,20 +89,41 @@ public void start() {
processManager = new NativeProcessManager("httpd-balancer", command, serverRoot(), null);
processManager.start();
- mcmpClient = new McmpClient("localhost", MCMP_PORT);
-
- // Poll until MCMP endpoint is responsive
- try {
- await().atMost(Duration.ofSeconds(30))
- .pollInterval(Duration.ofSeconds(1))
- .ignoreExceptions()
- .until(() -> {
- mcmpClient.sendInfo();
- return true;
- });
- } catch (Exception timeout) {
- logHttpdDiagnostics();
- throw timeout;
+ boolean skipModProxyCluster = Boolean.getBoolean("httpd.skip.mod_proxy_cluster");
+
+ if (skipModProxyCluster) {
+ // Poll HTTP port directly — no MCMP without mod_proxy_cluster
+ try {
+ await().atMost(Duration.ofSeconds(30))
+ .pollInterval(Duration.ofSeconds(1))
+ .ignoreExceptions()
+ .until(() -> {
+ HttpURLConnection conn = (HttpURLConnection)
+ new URL("http://localhost:" + HTTP_PORT + "/").openConnection();
+ conn.setConnectTimeout(2000);
+ conn.getResponseCode();
+ return true;
+ });
+ } catch (Exception timeout) {
+ logHttpdDiagnostics();
+ throw timeout;
+ }
+ } else {
+ mcmpClient = new McmpClient("localhost", MCMP_PORT);
+
+ // Poll until MCMP endpoint is responsive
+ try {
+ await().atMost(Duration.ofSeconds(30))
+ .pollInterval(Duration.ofSeconds(1))
+ .ignoreExceptions()
+ .until(() -> {
+ mcmpClient.sendInfo();
+ return true;
+ });
+ } catch (Exception timeout) {
+ logHttpdDiagnostics();
+ throw timeout;
+ }
}
log.info("Native httpd balancer started at {}", httpdHome);
@@ -238,7 +261,10 @@ private void setupSystemHttpdWorkDir() throws IOException {
"slotmem_shm_module:mod_slotmem_shm.so",
"watchdog_module:mod_watchdog.so",
"ssl_module:mod_ssl.so",
- "socache_shmcb_module:mod_socache_shmcb.so")) {
+ "socache_shmcb_module:mod_socache_shmcb.so",
+ "headers_module:mod_headers.so",
+ "env_module:mod_env.so",
+ "setenvif_module:mod_setenvif.so")) {
String[] parts = module.split(":");
Path soFile = systemModules.toAbsolutePath().resolve(parts[1]);
conf.append("\n");
@@ -246,39 +272,43 @@ private void setupSystemHttpdWorkDir() throws IOException {
conf.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("\n");
- conf.append(" LoadModule ").append(parts[0]).append(" ")
- .append(soFile.toAbsolutePath()).append("\n");
- conf.append("\n");
+ boolean skipModProxyCluster = Boolean.getBoolean("httpd.skip.mod_proxy_cluster");
+ if (!skipModProxyCluster) {
+ 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("\n");
+ conf.append(" LoadModule ").append(parts[0]).append(" ")
+ .append(soFile.toAbsolutePath()).append("\n");
+ conf.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("\n");
- conf.append(" LoadModule ").append(parts[0]).append(" ")
- .append(soFile.toAbsolutePath()).append("\n");
- conf.append("\n");
+ 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("\n");
+ conf.append(" LoadModule ").append(parts[0]).append(" ")
+ .append(soFile.toAbsolutePath()).append("\n");
+ conf.append("\n");
+ }
}
}
conf.append("\n#Listen 80\n");
conf.append("Listen 8080\n\n");
+ // AJP auth config must be included BEFORE conf.d/ so ProxyPass takes
+ // priority over mod_proxy_cluster's handler
+ conf.append("IncludeOptional conf/extra/ajp-*.conf\n");
// MCMP, VirtualHost, and SSL includes come from conf.d/mod_proxy_cluster.conf
conf.append("IncludeOptional conf.d/*.conf\n");
@@ -286,8 +316,9 @@ private void setupSystemHttpdWorkDir() throws IOException {
Files.writeString(confFile, conf.toString());
log.info("Generated httpd.conf at {}", confFile);
- // Copy mod_proxy_cluster.conf template to conf.d/
- copyModProxyClusterConf();
+ if (!skipModProxyCluster) {
+ copyModProxyClusterConf();
+ }
}
/**
@@ -599,13 +630,26 @@ public void reload() throws Exception {
result.getExitCode(), result.getStderr());
}
}
- await().atMost(Duration.ofSeconds(10))
- .pollInterval(Duration.ofMillis(500))
- .ignoreExceptions()
- .until(() -> {
- mcmpClient.sendInfo();
- return true;
- });
+ if (mcmpClient != null) {
+ await().atMost(Duration.ofSeconds(10))
+ .pollInterval(Duration.ofMillis(500))
+ .ignoreExceptions()
+ .until(() -> {
+ mcmpClient.sendInfo();
+ return true;
+ });
+ } else {
+ await().atMost(Duration.ofSeconds(10))
+ .pollInterval(Duration.ofMillis(500))
+ .ignoreExceptions()
+ .until(() -> {
+ java.net.HttpURLConnection conn = (java.net.HttpURLConnection)
+ new java.net.URL("http://localhost:" + HTTP_PORT + "/").openConnection();
+ conn.setConnectTimeout(2000);
+ conn.getResponseCode();
+ return true;
+ });
+ }
log.info("httpd balancer reloaded successfully");
}
diff --git a/src/test/resources/apps/secured/jboss-web.xml b/src/test/resources/apps/secured/jboss-web.xml
new file mode 100644
index 0000000..c56d0e8
--- /dev/null
+++ b/src/test/resources/apps/secured/jboss-web.xml
@@ -0,0 +1,4 @@
+
+
+ ajp-auth-domain
+
diff --git a/src/test/resources/apps/secured/web.xml b/src/test/resources/apps/secured/web.xml
new file mode 100644
index 0000000..ee497a1
--- /dev/null
+++ b/src/test/resources/apps/secured/web.xml
@@ -0,0 +1,28 @@
+
+
+
+ Secured Test Application
+
+
+
+ Secured
+ /*
+
+
+ gooduser
+
+
+
+
+ EXTERNAL
+
+
+
+ gooduser
+
+
+
diff --git a/src/test/resources/httpd/mod_proxy_cluster.conf b/src/test/resources/httpd/mod_proxy_cluster.conf
index 6c42ef2..320b80c 100644
--- a/src/test/resources/httpd/mod_proxy_cluster.conf
+++ b/src/test/resources/httpd/mod_proxy_cluster.conf
@@ -47,6 +47,9 @@
LoadModule proxy_wstunnel_module modules/mod_proxy_wstunnel.so
+
+ LoadModule headers_module modules/mod_headers.so
+
ProxyPreserveHost On
@@ -77,5 +80,5 @@ ManagerBalancerName mycluster
-# Include optional SSL configuration files (added dynamically during tests)
+# Include optional configuration files (added dynamically during tests)
IncludeOptional conf/extra/ssl-*.conf
diff --git a/src/test/resources/httpd/mod_proxy_cluster_ssl.conf b/src/test/resources/httpd/mod_proxy_cluster_ssl.conf
index 49417c9..5a3d069 100644
--- a/src/test/resources/httpd/mod_proxy_cluster_ssl.conf
+++ b/src/test/resources/httpd/mod_proxy_cluster_ssl.conf
@@ -105,5 +105,5 @@ ManagerBalancerName mycluster
SSLVerifyDepth 3
-# Include optional SSL configuration files (CRL config added dynamically during tests)
+# Include optional configuration files (added dynamically during tests)
IncludeOptional conf/extra/ssl-*.conf
From f1a17a7a8d8c73368e912ac20bbe431159d9b301 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Ka=C5=A1=C3=ADk?=
Date: Tue, 2 Jun 2026 15:16:35 +0200
Subject: [PATCH 2/6] Further improvements
---
README.md | 1 -
.../test/auth/AjpAuthPropagationTest.java | 14 ++------------
.../test/base/ModClusterTestExtension.java | 3 +++
.../test/base/SkipModProxyCluster.java | 17 +++++++++++++++++
.../test/utils/balancer/Balancer.java | 16 ++++++++++++++++
.../utils/balancer/NativeHttpdBalancer.java | 3 ---
6 files changed, 38 insertions(+), 16 deletions(-)
create mode 100644 src/test/java/org/jboss/modcluster/test/base/SkipModProxyCluster.java
diff --git a/README.md b/README.md
index 49f7ef5..1d02286 100644
--- a/README.md
+++ b/README.md
@@ -428,7 +428,6 @@ In practice, always provide a WildFly/EAP ZIP — the fallback images are not pu
| `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.skip.mod_proxy_cluster` | Native | `false` | Skip mod_proxy_cluster modules (for direct AJP proxy tests) |
| `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 |
diff --git a/src/test/java/org/jboss/modcluster/test/auth/AjpAuthPropagationTest.java b/src/test/java/org/jboss/modcluster/test/auth/AjpAuthPropagationTest.java
index 52d67b7..6cfc275 100644
--- a/src/test/java/org/jboss/modcluster/test/auth/AjpAuthPropagationTest.java
+++ b/src/test/java/org/jboss/modcluster/test/auth/AjpAuthPropagationTest.java
@@ -3,12 +3,11 @@
import org.jboss.modcluster.test.apps.SecuredAppBuilder;
import org.jboss.modcluster.test.base.ModClusterTestExtension;
import org.jboss.modcluster.test.base.ModClusterTestExtension.TestCluster;
+import org.jboss.modcluster.test.base.SkipModProxyCluster;
import org.jboss.modcluster.test.utils.HttpClient;
import org.jboss.modcluster.test.utils.HttpClient.HttpResponse;
import org.jboss.modcluster.test.utils.TestTimeouts;
import org.jboss.modcluster.test.utils.WildFlyWorker;
-import org.junit.jupiter.api.AfterAll;
-import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -38,21 +37,12 @@
* protocol path used by IIS/isapi_redirect after Windows authentication.
*/
@Tag("native")
+@SkipModProxyCluster
@ExtendWith(ModClusterTestExtension.class)
public class AjpAuthPropagationTest {
private static final Logger log = LoggerFactory.getLogger(AjpAuthPropagationTest.class);
- @BeforeAll
- static void disableModProxyCluster() {
- System.setProperty("httpd.skip.mod_proxy_cluster", "true");
- }
-
- @AfterAll
- static void restoreModProxyCluster() {
- System.clearProperty("httpd.skip.mod_proxy_cluster");
- }
-
private static final int AJP_BASE_PORT = 8019;
private static final int AJP_PORT = 8119; // base + worker1 offset (100)
private static final String AJP_SOCKET_BINDING = "ajp-test";
diff --git a/src/test/java/org/jboss/modcluster/test/base/ModClusterTestExtension.java b/src/test/java/org/jboss/modcluster/test/base/ModClusterTestExtension.java
index a37bca6..c6684ce 100644
--- a/src/test/java/org/jboss/modcluster/test/base/ModClusterTestExtension.java
+++ b/src/test/java/org/jboss/modcluster/test/base/ModClusterTestExtension.java
@@ -34,6 +34,9 @@ public void beforeEach(ExtensionContext context) {
// Create balancer and store BEFORE start — so afterEach can clean up network even if start fails
Balancer balancer = Balancer.create(balancerType);
+ if (context.getRequiredTestClass().isAnnotationPresent(SkipModProxyCluster.class)) {
+ balancer.setSkipModProxyCluster(true);
+ }
store.put(BALANCER_KEY, balancer);
balancer.start();
diff --git a/src/test/java/org/jboss/modcluster/test/base/SkipModProxyCluster.java b/src/test/java/org/jboss/modcluster/test/base/SkipModProxyCluster.java
new file mode 100644
index 0000000..c981cb2
--- /dev/null
+++ b/src/test/java/org/jboss/modcluster/test/base/SkipModProxyCluster.java
@@ -0,0 +1,17 @@
+package org.jboss.modcluster.test.base;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a test class that requires httpd without mod_proxy_cluster.
+ * The {@link ModClusterTestExtension} reads this annotation and configures
+ * {@code NativeHttpdBalancer} to skip loading mod_proxy_cluster modules,
+ * allowing direct {@code ProxyPass} / {@code mod_proxy_ajp} usage.
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface SkipModProxyCluster {
+}
diff --git a/src/test/java/org/jboss/modcluster/test/utils/balancer/Balancer.java b/src/test/java/org/jboss/modcluster/test/utils/balancer/Balancer.java
index 6e00388..2b97a78 100644
--- a/src/test/java/org/jboss/modcluster/test/utils/balancer/Balancer.java
+++ b/src/test/java/org/jboss/modcluster/test/utils/balancer/Balancer.java
@@ -37,6 +37,22 @@ public abstract class Balancer {
/** Time a broken node stays registered before removal (milliseconds). */
public static final int BROKEN_NODE_TIMEOUT_MS = 3000;
+ // ---- httpd-specific configuration ----
+ // These fields are only read by httpd balancer implementations
+ // (NativeHttpdBalancer, DockerHttpdBalancer). Undertow balancers ignore them.
+
+ /**
+ * When {@code true}, httpd starts without mod_proxy_cluster modules loaded.
+ * This allows direct {@code ProxyPass} / {@code mod_proxy_ajp} usage, which
+ * mod_proxy_cluster's global proxy handler would otherwise intercept.
+ * Set via the {@link org.jboss.modcluster.test.base.SkipModProxyCluster} annotation.
+ */
+ protected boolean skipModProxyCluster;
+
+ public void setSkipModProxyCluster(boolean skip) {
+ this.skipModProxyCluster = skip;
+ }
+
/**
* Create a balancer for the given type and current test mode.
*
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 024e238..5ec6d24 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
@@ -89,8 +89,6 @@ public void start() {
processManager = new NativeProcessManager("httpd-balancer", command, serverRoot(), null);
processManager.start();
- boolean skipModProxyCluster = Boolean.getBoolean("httpd.skip.mod_proxy_cluster");
-
if (skipModProxyCluster) {
// Poll HTTP port directly — no MCMP without mod_proxy_cluster
try {
@@ -272,7 +270,6 @@ private void setupSystemHttpdWorkDir() throws IOException {
conf.append("\n");
}
- boolean skipModProxyCluster = Boolean.getBoolean("httpd.skip.mod_proxy_cluster");
if (!skipModProxyCluster) {
conf.append("\n# mod_proxy_cluster modules\n");
Path mpcModules = modulesPath != null ? modulesPath : systemModules;
From 701155ec7d82fee7dce58bdfb9b893561bda1fe2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Ka=C5=A1=C3=ADk?=
Date: Tue, 2 Jun 2026 22:24:54 +0200
Subject: [PATCH 3/6] Add Docker support for AJP auth propagation test
Enable AjpAuthPropagationTest to run in both Docker and native mode.
Docker changes:
- DockerHttpdBalancer: skipModProxyCluster branch that starts httpd
without mod_proxy_cluster, with --init + exec for proper signal
delivery on graceful restart
- AjpAuthConfigurator: ajpHost parameter for Docker networking
(containers use network aliases, not localhost), proxy module
loading with IfModule guards, htpasswd path fallback
- Test computes AJP host/port per mode and uses auth-aware readiness
polling to verify the full AJP path is up before asserting
Added to main TEST_CLASS so it runs in both Docker and native CI.
---
.github/workflows/ci.yml | 2 +-
README.md | 2 +-
.../test/auth/AjpAuthConfigurator.java | 20 ++++-
.../test/auth/AjpAuthPropagationTest.java | 53 ++++++-----
.../utils/balancer/DockerHttpdBalancer.java | 88 +++++++++++++------
5 files changed, 112 insertions(+), 53 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 86931f7..f393e35 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,SslCrlTest
+ TEST_CLASS: StickySessionTest,SslFailoverTest,SslCrlTest,AjpAuthPropagationTest
MOD_PROXY_CLUSTER_REPO: https://github.com/modcluster/mod_proxy_cluster.git
jobs:
diff --git a/README.md b/README.md
index 1d02286..2c3c991 100644
--- a/README.md
+++ b/README.md
@@ -293,7 +293,7 @@ String result = worker.executeCli("/subsystem=modcluster:read-resource");
- **SslWorkerAuthenticationTest** - Mutual SSL authentication
### Authentication Tests
-- **AjpAuthPropagationTest** - REMOTE_USER propagation via AJP (Elytron EXTERNAL mechanism)
+- **AjpAuthPropagationTest** - REMOTE_USER propagation via AJP (Elytron EXTERNAL mechanism, Docker and native)
### Load Balancing Tests
- **LoadBalancingGroupFailoverTest** - Load distribution and group failover
diff --git a/src/test/java/org/jboss/modcluster/test/auth/AjpAuthConfigurator.java b/src/test/java/org/jboss/modcluster/test/auth/AjpAuthConfigurator.java
index c26c1c1..eb5da85 100644
--- a/src/test/java/org/jboss/modcluster/test/auth/AjpAuthConfigurator.java
+++ b/src/test/java/org/jboss/modcluster/test/auth/AjpAuthConfigurator.java
@@ -1,6 +1,7 @@
package org.jboss.modcluster.test.auth;
import org.jboss.dmr.ModelNode;
+import org.jboss.modcluster.test.utils.CommandResult;
import org.jboss.modcluster.test.utils.WildFlyWorker;
import org.jboss.modcluster.test.utils.balancer.Balancer;
import org.slf4j.Logger;
@@ -115,9 +116,15 @@ public void configureWorker(WildFlyWorker worker, UserEntry... users) throws Exc
* @param username the username to inject as REMOTE_USER
* @param ajpPort the worker's AJP listener port
*/
- public void configureBalancerRemoteUser(Balancer balancer, String username, int ajpPort)
+ public void configureBalancerRemoteUser(Balancer balancer, String username, String ajpHost, int ajpPort)
throws Exception {
StringBuilder conf = new StringBuilder();
+ conf.append("\n");
+ conf.append(" LoadModule proxy_module modules/mod_proxy.so\n");
+ conf.append("\n");
+ conf.append("\n");
+ conf.append(" LoadModule proxy_ajp_module modules/mod_proxy_ajp.so\n");
+ conf.append("\n");
conf.append("\n");
conf.append(" LoadModule authn_file_module modules/mod_authn_file.so\n");
conf.append("\n");
@@ -131,8 +138,8 @@ public void configureBalancerRemoteUser(Balancer balancer, String username, int
conf.append(" LoadModule auth_basic_module modules/mod_auth_basic.so\n");
conf.append("\n\n");
- conf.append("ProxyPass /secured/ ajp://localhost:").append(ajpPort).append("/secured/\n");
- conf.append("ProxyPassReverse /secured/ ajp://localhost:").append(ajpPort).append("/secured/\n\n");
+ conf.append("ProxyPass /secured/ ajp://").append(ajpHost).append(":").append(ajpPort).append("/secured/\n");
+ conf.append("ProxyPassReverse /secured/ ajp://").append(ajpHost).append(":").append(ajpPort).append("/secured/\n\n");
if (username != null) {
String htpasswdPath = balancer.getServerHome() + "/conf/test-users.htpasswd";
@@ -144,7 +151,12 @@ public void configureBalancerRemoteUser(Balancer balancer, String username, int
conf.append(" Require valid-user\n");
conf.append("\n");
- balancer.execCommand("htpasswd", "-cb", htpasswdPath, username, "password");
+ CommandResult result = balancer.execCommand("htpasswd", "-cb",
+ htpasswdPath, username, "password");
+ if (!result.isSuccess()) {
+ balancer.execCommand(balancer.getServerHome() + "/bin/htpasswd",
+ "-cb", htpasswdPath, username, "password");
+ }
}
Path tempConf = Files.createTempFile("ajp-auth", ".conf");
diff --git a/src/test/java/org/jboss/modcluster/test/auth/AjpAuthPropagationTest.java b/src/test/java/org/jboss/modcluster/test/auth/AjpAuthPropagationTest.java
index 6cfc275..03beea7 100644
--- a/src/test/java/org/jboss/modcluster/test/auth/AjpAuthPropagationTest.java
+++ b/src/test/java/org/jboss/modcluster/test/auth/AjpAuthPropagationTest.java
@@ -6,9 +6,9 @@
import org.jboss.modcluster.test.base.SkipModProxyCluster;
import org.jboss.modcluster.test.utils.HttpClient;
import org.jboss.modcluster.test.utils.HttpClient.HttpResponse;
+import org.jboss.modcluster.test.utils.TestMode;
import org.jboss.modcluster.test.utils.TestTimeouts;
import org.jboss.modcluster.test.utils.WildFlyWorker;
-import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.slf4j.Logger;
@@ -36,15 +36,13 @@
* Uses a direct {@code ProxyPass ajp://} to the worker's AJP port, which is the same
* protocol path used by IIS/isapi_redirect after Windows authentication.
*/
-@Tag("native")
@SkipModProxyCluster
@ExtendWith(ModClusterTestExtension.class)
public class AjpAuthPropagationTest {
private static final Logger log = LoggerFactory.getLogger(AjpAuthPropagationTest.class);
- private static final int AJP_BASE_PORT = 8019;
- private static final int AJP_PORT = 8119; // base + worker1 offset (100)
+ private static final int AJP_CUSTOM_PORT = 8019;
private static final String AJP_SOCKET_BINDING = "ajp-test";
private static final String AJP_LISTENER = "ajp-test-listener";
@@ -57,8 +55,6 @@ public void testAuthenticatedUserCanAccessSecuredServlet(final TestCluster clust
final HttpClient httpClient) throws Exception {
AjpAuthConfigurator configurator = new AjpAuthConfigurator();
- configurator.configureBalancerRemoteUser(cluster.getBalancer(), "testuser", AJP_PORT);
-
cluster.startWorkers(1);
WildFlyWorker worker = cluster.getWorker1();
@@ -66,13 +62,17 @@ public void testAuthenticatedUserCanAccessSecuredServlet(final TestCluster clust
new AjpAuthConfigurator.UserEntry("testuser", "gooduser"));
addAjpListener(worker);
+ configurator.configureBalancerRemoteUser(cluster.getBalancer(), "testuser",
+ ajpHost(worker), ajpPort());
+
File securedWar = SecuredAppBuilder.createSecuredApp();
worker.deployment().deploy(securedWar);
String url = cluster.getBalancer().getHttpUrl() + "/secured/secured";
- awaitAjpAvailable(httpClient, url);
+ Map authHeaders = basicAuthHeaders("testuser", "password");
+ awaitAjpAvailable(httpClient, url, authHeaders);
- HttpResponse response = httpClient.get(url, basicAuthHeaders("testuser", "password"));
+ HttpResponse response = httpClient.get(url, authHeaders);
log.info("Response: status={}, body={}", response.getStatusCode(), response.getBody());
assertThat(response.getStatusCode()).isEqualTo(200);
@@ -88,9 +88,6 @@ public void testNoRemoteUserIsRejected(final TestCluster cluster,
final HttpClient httpClient) throws Exception {
AjpAuthConfigurator configurator = new AjpAuthConfigurator();
- // ProxyPass without Basic auth — no REMOTE_USER in AJP
- configurator.configureBalancerRemoteUser(cluster.getBalancer(), null, AJP_PORT);
-
cluster.startWorkers(1);
WildFlyWorker worker = cluster.getWorker1();
@@ -98,11 +95,15 @@ public void testNoRemoteUserIsRejected(final TestCluster cluster,
new AjpAuthConfigurator.UserEntry("testuser", "gooduser"));
addAjpListener(worker);
+ // ProxyPass without Basic auth — no REMOTE_USER in AJP
+ configurator.configureBalancerRemoteUser(cluster.getBalancer(), null,
+ ajpHost(worker), ajpPort());
+
File securedWar = SecuredAppBuilder.createSecuredApp();
worker.deployment().deploy(securedWar);
String url = cluster.getBalancer().getHttpUrl() + "/secured/secured";
- awaitAjpAvailable(httpClient, url);
+ awaitAjpAvailable(httpClient, url, null);
HttpResponse response = httpClient.get(url);
@@ -119,8 +120,6 @@ public void testUnauthorizedUserIsRejected(final TestCluster cluster,
final HttpClient httpClient) throws Exception {
AjpAuthConfigurator configurator = new AjpAuthConfigurator();
- configurator.configureBalancerRemoteUser(cluster.getBalancer(), "baduser", AJP_PORT);
-
cluster.startWorkers(1);
WildFlyWorker worker = cluster.getWorker1();
@@ -129,13 +128,17 @@ public void testUnauthorizedUserIsRejected(final TestCluster cluster,
new AjpAuthConfigurator.UserEntry("baduser", "badrole"));
addAjpListener(worker);
+ configurator.configureBalancerRemoteUser(cluster.getBalancer(), "baduser",
+ ajpHost(worker), ajpPort());
+
File securedWar = SecuredAppBuilder.createSecuredApp();
worker.deployment().deploy(securedWar);
String url = cluster.getBalancer().getHttpUrl() + "/secured/secured";
- awaitAjpAvailable(httpClient, url);
+ Map authHeaders = basicAuthHeaders("baduser", "password");
+ awaitAjpAvailable(httpClient, url, authHeaders);
- HttpResponse response = httpClient.get(url, basicAuthHeaders("baduser", "password"));
+ HttpResponse response = httpClient.get(url, authHeaders);
log.info("Response (wrong role): status={}", response.getStatusCode());
assertThat(response.getStatusCode()).isEqualTo(403);
@@ -146,7 +149,7 @@ private void addAjpListener(WildFlyWorker worker) throws Exception {
Address sbAddr = Address.of("socket-binding-group", "standard-sockets")
.and("socket-binding", AJP_SOCKET_BINDING);
if (!ops.exists(sbAddr)) {
- worker.undertow().addSocketBinding(AJP_SOCKET_BINDING, AJP_BASE_PORT);
+ worker.undertow().addSocketBinding(AJP_SOCKET_BINDING, AJP_CUSTOM_PORT);
}
Address listenerAddr = Address.subsystem("undertow")
.and("server", "default-server")
@@ -155,15 +158,25 @@ private void addAjpListener(WildFlyWorker worker) throws Exception {
worker.undertow().addAjpListener(AJP_LISTENER, "default-server", AJP_SOCKET_BINDING);
worker.reload();
}
- log.info("AJP listener on port {} ready", AJP_PORT);
+ log.info("AJP listener on port {} ready", ajpPort());
+ }
+
+ private static String ajpHost(WildFlyWorker worker) {
+ return TestMode.current().isNative() ? "localhost" : worker.getName();
+ }
+
+ private static int ajpPort() {
+ // Native: custom port + worker1 offset (100). Docker: custom port (no offset).
+ return TestMode.current().isNative() ? AJP_CUSTOM_PORT + 100 : AJP_CUSTOM_PORT;
}
- private void awaitAjpAvailable(HttpClient httpClient, String url) {
+ private void awaitAjpAvailable(HttpClient httpClient, String url, Map headers) {
await().atMost(TestTimeouts.CLUSTER_FORMATION)
.pollInterval(ofSeconds(2))
.ignoreExceptions()
.untilAsserted(() -> {
- HttpResponse response = httpClient.get(url);
+ HttpResponse response = headers != null
+ ? httpClient.get(url, headers) : httpClient.get(url);
assertThat(response.getStatusCode()).isLessThan(500);
});
log.info("AJP proxy responding at {}", url);
diff --git a/src/test/java/org/jboss/modcluster/test/utils/balancer/DockerHttpdBalancer.java b/src/test/java/org/jboss/modcluster/test/utils/balancer/DockerHttpdBalancer.java
index b1f5b81..7bf9a38 100644
--- a/src/test/java/org/jboss/modcluster/test/utils/balancer/DockerHttpdBalancer.java
+++ b/src/test/java/org/jboss/modcluster/test/utils/balancer/DockerHttpdBalancer.java
@@ -16,6 +16,8 @@
import static org.awaitility.Awaitility.await;
import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URL;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
@@ -82,33 +84,52 @@ private void startContainer(final String networkAlias) {
}
ContainerUtils.startWithRetry(() -> {
- container = new GenericContainer<>(DockerImageName.parse(imageName))
+ GenericContainer> c = new GenericContainer<>(DockerImageName.parse(imageName))
.withNetwork(network)
.withNetworkAliases(networkAlias)
- .withExposedPorts(HTTP_PORT, HTTPS_PORT, MCMP_PORT)
- .withCopyFileToContainer(
- MountableFile.forClasspathResource("httpd/mod_proxy_cluster.conf", 0644),
- "/usr/local/apache2/conf/extra/mod_proxy_cluster.conf")
- .withCommand("/bin/sh", "-c",
- // Disable mod_proxy_balancer (conflicts with mod_proxy_cluster),
- // replace default Listen 80 with Listen 8080, include our config, and start httpd
- "sed -i 's/^LoadModule proxy_balancer_module/#LoadModule proxy_balancer_module/' " +
- "/usr/local/apache2/conf/httpd.conf && " +
- "sed -i 's/^\\(Listen 80\\)$/#\\1/' /usr/local/apache2/conf/httpd.conf && " +
- "echo 'Listen 8080' >> /usr/local/apache2/conf/httpd.conf && " +
- "echo 'Include conf/extra/mod_proxy_cluster.conf' >> /usr/local/apache2/conf/httpd.conf && " +
- "echo 'ErrorLog /proc/self/fd/2' >> /usr/local/apache2/conf/httpd.conf && " +
- "echo 'LogLevel info' >> /usr/local/apache2/conf/httpd.conf && " +
- "/usr/local/apache2/bin/httpd -DFOREGROUND")
- .waitingFor(Wait.forHttp("/mod_cluster_manager").forPort(MCMP_PORT)
- .withStartupTimeout(TestTimeouts.HTTPD_STARTUP))
+ .withCreateContainerCmdModifier(cmd -> cmd.getHostConfig().withInit(true))
+ .withExposedPorts(skipModProxyCluster
+ ? new Integer[]{HTTP_PORT}
+ : new Integer[]{HTTP_PORT, HTTPS_PORT, MCMP_PORT})
.withLogConsumer(outputFrame ->
log.info("[HTTPD-{}] {}", networkAlias.toUpperCase(),
outputFrame.getUtf8String().trim()));
+ if (skipModProxyCluster) {
+ c.withCommand("/bin/sh", "-c",
+ "sed -i 's/^\\(Listen 80\\)$/#\\1/' /usr/local/apache2/conf/httpd.conf && " +
+ "echo 'Listen 8080' >> /usr/local/apache2/conf/httpd.conf && " +
+ "echo 'PidFile /usr/local/apache2/logs/httpd.pid' >> /usr/local/apache2/conf/httpd.conf && " +
+ "echo 'IncludeOptional conf/extra/ajp-*.conf' >> /usr/local/apache2/conf/httpd.conf && " +
+ "echo 'IncludeOptional conf/extra/ssl-*.conf' >> /usr/local/apache2/conf/httpd.conf && " +
+ "echo 'ErrorLog /proc/self/fd/2' >> /usr/local/apache2/conf/httpd.conf && " +
+ "echo 'LogLevel info' >> /usr/local/apache2/conf/httpd.conf && " +
+ "exec /usr/local/apache2/bin/httpd -DFOREGROUND")
+ .waitingFor(Wait.forListeningPort()
+ .withStartupTimeout(TestTimeouts.HTTPD_STARTUP));
+ } else {
+ c.withCopyFileToContainer(
+ MountableFile.forClasspathResource("httpd/mod_proxy_cluster.conf", 0644),
+ "/usr/local/apache2/conf/extra/mod_proxy_cluster.conf")
+ .withCommand("/bin/sh", "-c",
+ "sed -i 's/^LoadModule proxy_balancer_module/#LoadModule proxy_balancer_module/' " +
+ "/usr/local/apache2/conf/httpd.conf && " +
+ "sed -i 's/^\\(Listen 80\\)$/#\\1/' /usr/local/apache2/conf/httpd.conf && " +
+ "echo 'Listen 8080' >> /usr/local/apache2/conf/httpd.conf && " +
+ "echo 'Include conf/extra/mod_proxy_cluster.conf' >> /usr/local/apache2/conf/httpd.conf && " +
+ "echo 'ErrorLog /proc/self/fd/2' >> /usr/local/apache2/conf/httpd.conf && " +
+ "echo 'LogLevel info' >> /usr/local/apache2/conf/httpd.conf && " +
+ "exec /usr/local/apache2/bin/httpd -DFOREGROUND")
+ .waitingFor(Wait.forHttp("/mod_cluster_manager").forPort(MCMP_PORT)
+ .withStartupTimeout(TestTimeouts.HTTPD_STARTUP));
+ }
+
+ container = c;
container.start();
- mcmpClient = new McmpClient(container.getHost(), container.getMappedPort(MCMP_PORT));
+ if (!skipModProxyCluster) {
+ mcmpClient = new McmpClient(container.getHost(), container.getMappedPort(MCMP_PORT));
+ }
log.info("Httpd balancer '{}' started on network: {}", networkAlias, network.getId());
}, () -> {
if (container != null) {
@@ -319,14 +340,27 @@ public void reload() throws Exception {
}
}
}
- // Wait for httpd to finish graceful restart by polling the MCMP endpoint
- await().atMost(Duration.ofSeconds(10))
- .pollInterval(Duration.ofMillis(500))
- .ignoreExceptions()
- .until(() -> {
- getMcmpClient().sendInfo();
- return true;
- });
+ if (mcmpClient != null) {
+ await().atMost(Duration.ofSeconds(10))
+ .pollInterval(Duration.ofMillis(500))
+ .ignoreExceptions()
+ .until(() -> {
+ getMcmpClient().sendInfo();
+ return true;
+ });
+ } else {
+ await().atMost(Duration.ofSeconds(10))
+ .pollInterval(Duration.ofMillis(500))
+ .ignoreExceptions()
+ .until(() -> {
+ HttpURLConnection conn = (HttpURLConnection)
+ new URL("http://" + container.getHost() + ":"
+ + container.getMappedPort(HTTP_PORT) + "/").openConnection();
+ conn.setConnectTimeout(2000);
+ conn.getResponseCode();
+ return true;
+ });
+ }
log.info("Httpd balancer reloaded successfully");
}
From 7054f9fe6d380e93a88c99669df1e90bf7b8a2e5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Ka=C5=A1=C3=ADk?=
Date: Tue, 2 Jun 2026 23:00:46 +0200
Subject: [PATCH 4/6] Exclude AJP auth propagation test from Undertow CI matrix
---
.github/workflows/ci.yml | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f393e35..c63c465 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,SslCrlTest,AjpAuthPropagationTest
+ TEST_CLASS: StickySessionTest,SslFailoverTest,SslCrlTest
MOD_PROXY_CLUSTER_REPO: https://github.com/modcluster/mod_proxy_cluster.git
jobs:
@@ -41,15 +41,25 @@ jobs:
shell: bash
run: mvn -B generate-test-resources -Pdownload-wildfly -Dwildfly.version=${{ env.WILDFLY_VERSION }} -DskipTests
+ - name: Set test classes
+ id: test-classes
+ shell: bash
+ run: |
+ TESTS="${{ env.TEST_CLASS }}"
+ if [ "${{ matrix.balancer }}" = "httpd" ]; then
+ TESTS="${TESTS},AjpAuthPropagationTest"
+ fi
+ echo "classes=${TESTS}" >> "$GITHUB_OUTPUT"
+
- name: Run tests (Docker)
if: runner.os != 'Windows'
shell: bash
- run: mvn -B test -Dtest=${{ env.TEST_CLASS }} -DexcludedGroups=none -Dbalancer.type=${{ matrix.balancer }} -Dwildfly.version=${{ env.WILDFLY_VERSION }}
+ run: mvn -B test -Dtest=${{ steps.test-classes.outputs.classes }} -DexcludedGroups=none -Dbalancer.type=${{ matrix.balancer }} -Dwildfly.version=${{ env.WILDFLY_VERSION }}
- name: Run tests (Native)
if: runner.os == 'Windows'
shell: bash
- run: mvn -B test -Pnative -Dtest=${{ env.TEST_CLASS }} -DexcludedGroups=none -Dbalancer.type=${{ matrix.balancer }} -Dwildfly.version=${{ env.WILDFLY_VERSION }}
+ run: mvn -B test -Pnative -Dtest=${{ steps.test-classes.outputs.classes }} -DexcludedGroups=none -Dbalancer.type=${{ matrix.balancer }} -Dwildfly.version=${{ env.WILDFLY_VERSION }}
- name: Publish test results
uses: mikepenz/action-junit-report@v6
From a561ffa723b9ceb798b3af0a67a99f57d11b319e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Ka=C5=A1=C3=ADk?=
Date: Tue, 2 Jun 2026 23:06:01 +0200
Subject: [PATCH 5/6] Revert "Exclude AJP auth propagation test from Undertow
CI matrix"
This reverts commit 7054f9fe6d380e93a88c99669df1e90bf7b8a2e5.
---
.github/workflows/ci.yml | 16 +++-------------
1 file changed, 3 insertions(+), 13 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c63c465..f393e35 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,SslCrlTest
+ TEST_CLASS: StickySessionTest,SslFailoverTest,SslCrlTest,AjpAuthPropagationTest
MOD_PROXY_CLUSTER_REPO: https://github.com/modcluster/mod_proxy_cluster.git
jobs:
@@ -41,25 +41,15 @@ jobs:
shell: bash
run: mvn -B generate-test-resources -Pdownload-wildfly -Dwildfly.version=${{ env.WILDFLY_VERSION }} -DskipTests
- - name: Set test classes
- id: test-classes
- shell: bash
- run: |
- TESTS="${{ env.TEST_CLASS }}"
- if [ "${{ matrix.balancer }}" = "httpd" ]; then
- TESTS="${TESTS},AjpAuthPropagationTest"
- fi
- echo "classes=${TESTS}" >> "$GITHUB_OUTPUT"
-
- name: Run tests (Docker)
if: runner.os != 'Windows'
shell: bash
- run: mvn -B test -Dtest=${{ steps.test-classes.outputs.classes }} -DexcludedGroups=none -Dbalancer.type=${{ matrix.balancer }} -Dwildfly.version=${{ env.WILDFLY_VERSION }}
+ run: mvn -B test -Dtest=${{ env.TEST_CLASS }} -DexcludedGroups=none -Dbalancer.type=${{ matrix.balancer }} -Dwildfly.version=${{ env.WILDFLY_VERSION }}
- name: Run tests (Native)
if: runner.os == 'Windows'
shell: bash
- run: mvn -B test -Pnative -Dtest=${{ steps.test-classes.outputs.classes }} -DexcludedGroups=none -Dbalancer.type=${{ matrix.balancer }} -Dwildfly.version=${{ env.WILDFLY_VERSION }}
+ 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@v6
From b7e2eb605586ae959da52f59b699eb2d9348b025 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Ka=C5=A1=C3=ADk?=
Date: Tue, 2 Jun 2026 23:06:05 +0200
Subject: [PATCH 6/6] Skip AjpAuthPropagationTest when balancer type is not
httpd
---
.github/workflows/ci.yml | 5 ++---
pom.xml | 1 +
.../jboss/modcluster/test/auth/AjpAuthPropagationTest.java | 2 ++
3 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f393e35..fff921f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -44,12 +44,12 @@ jobs:
- name: Run tests (Docker)
if: runner.os != 'Windows'
shell: bash
- run: mvn -B test -Dtest=${{ env.TEST_CLASS }} -DexcludedGroups=none -Dbalancer.type=${{ matrix.balancer }} -Dwildfly.version=${{ env.WILDFLY_VERSION }}
+ run: mvn -B test -Dtest=${{ env.TEST_CLASS }} -Dbalancer.type=${{ matrix.balancer }} -Dwildfly.version=${{ env.WILDFLY_VERSION }}
- name: Run tests (Native)
if: runner.os == 'Windows'
shell: bash
- run: mvn -B test -Pnative -Dtest=${{ env.TEST_CLASS }} -DexcludedGroups=none -Dbalancer.type=${{ matrix.balancer }} -Dwildfly.version=${{ env.WILDFLY_VERSION }}
+ run: mvn -B test -Pnative -Dtest=${{ env.TEST_CLASS }} -Dbalancer.type=${{ matrix.balancer }} -Dwildfly.version=${{ env.WILDFLY_VERSION }}
- name: Publish test results
uses: mikepenz/action-junit-report@v6
@@ -101,7 +101,6 @@ jobs:
run: |
mvn -B test -Pnative \
-Dtest=${{ env.TEST_CLASS }},AjpAuthPropagationTest \
- -DexcludedGroups=none \
-Dbalancer.type=httpd \
-Dhttpd.home=/usr \
-Dhttpd.modules.path=${{ github.workspace }}/target/mod_proxy_cluster/native/build/modules \
diff --git a/pom.xml b/pom.xml
index 4fa03a9..4db4f8f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -308,6 +308,7 @@
undertow
+ httpd
diff --git a/src/test/java/org/jboss/modcluster/test/auth/AjpAuthPropagationTest.java b/src/test/java/org/jboss/modcluster/test/auth/AjpAuthPropagationTest.java
index 03beea7..af6ca5c 100644
--- a/src/test/java/org/jboss/modcluster/test/auth/AjpAuthPropagationTest.java
+++ b/src/test/java/org/jboss/modcluster/test/auth/AjpAuthPropagationTest.java
@@ -9,6 +9,7 @@
import org.jboss.modcluster.test.utils.TestMode;
import org.jboss.modcluster.test.utils.TestTimeouts;
import org.jboss.modcluster.test.utils.WildFlyWorker;
+import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.slf4j.Logger;
@@ -36,6 +37,7 @@
* Uses a direct {@code ProxyPass ajp://} to the worker's AJP port, which is the same
* protocol path used by IIS/isapi_redirect after Windows authentication.
*/
+@Tag("httpd")
@SkipModProxyCluster
@ExtendWith(ModClusterTestExtension.class)
public class AjpAuthPropagationTest {