diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cddd569..fff921f 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: @@ -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 @@ -100,8 +100,7 @@ jobs: - name: Run tests run: | mvn -B test -Pnative \ - -Dtest=${{ env.TEST_CLASS }} \ - -DexcludedGroups=none \ + -Dtest=${{ env.TEST_CLASS }},AjpAuthPropagationTest \ -Dbalancer.type=httpd \ -Dhttpd.home=/usr \ -Dhttpd.modules.path=${{ github.workspace }}/target/mod_proxy_cluster/native/build/modules \ diff --git a/README.md b/README.md index 456c5fe..2c3c991 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, Docker and native) + ### 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 | 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/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..eb5da85 --- /dev/null +++ b/src/test/java/org/jboss/modcluster/test/auth/AjpAuthConfigurator.java @@ -0,0 +1,180 @@ +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; +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, 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"); + 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://").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"; + 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"); + + 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"); + 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..af6ca5c --- /dev/null +++ b/src/test/java/org/jboss/modcluster/test/auth/AjpAuthPropagationTest.java @@ -0,0 +1,194 @@ +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.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; +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("httpd") +@SkipModProxyCluster +@ExtendWith(ModClusterTestExtension.class) +public class AjpAuthPropagationTest { + + private static final Logger log = LoggerFactory.getLogger(AjpAuthPropagationTest.class); + + 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"; + + /** + * 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(); + + cluster.startWorkers(1); + WildFlyWorker worker = cluster.getWorker1(); + + configurator.configureWorker(worker, + 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"; + Map authHeaders = basicAuthHeaders("testuser", "password"); + awaitAjpAvailable(httpClient, url, authHeaders); + + HttpResponse response = httpClient.get(url, authHeaders); + + 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(); + + cluster.startWorkers(1); + WildFlyWorker worker = cluster.getWorker1(); + + configurator.configureWorker(worker, + 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, null); + + 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(); + + cluster.startWorkers(1); + WildFlyWorker worker = cluster.getWorker1(); + + configurator.configureWorker(worker, + new AjpAuthConfigurator.UserEntry("testuser", "gooduser"), + 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"; + Map authHeaders = basicAuthHeaders("baduser", "password"); + awaitAjpAvailable(httpClient, url, authHeaders); + + HttpResponse response = httpClient.get(url, authHeaders); + + 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_CUSTOM_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", 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, Map headers) { + await().atMost(TestTimeouts.CLUSTER_FORMATION) + .pollInterval(ofSeconds(2)) + .ignoreExceptions() + .untilAsserted(() -> { + HttpResponse response = headers != null + ? httpClient.get(url, headers) : 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/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/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"); } 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..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 @@ -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,39 @@ 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; + 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 +259,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 +270,42 @@ 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"); + 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 +313,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 +627,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