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 {