diff --git a/BUILDING.txt b/BUILDING.txt index 8ee307952018..6a4a83ba0ca0 100644 --- a/BUILDING.txt +++ b/BUILDING.txt @@ -603,6 +603,73 @@ NOTE: Cobertura is licensed under GPL v2 with parts of it being under test.verbose=false +(7.5) Running httpd integration tests + + Tomcat includes integration tests that verify Tomcat's behavior when + running behind an Apache HTTP Server (httpd) reverse proxy. These tests + require a working httpd installation and are skipped when httpd is not + available. + + 1. Prerequisites + + An Apache HTTP Server (httpd) installation is required. + The following httpd modules must be available: + + - mod_proxy + - mod_proxy_http + - mod_headers + - mod_ssl + - mod_authz_core + - mod_unixd (Linux and macOS only) + - mod_mpm_event or mod_mpm_prefork (Linux and macOS) + + On most Linux distributions, httpd can be installed via the system + package manager: + + # Debian/Ubuntu + sudo apt install apache2 + + # RHEL/Fedora + sudo dnf install httpd + + On macOS, httpd is included with the system or can be installed via + Homebrew: + + brew install httpd + + 2. Configuration + + If the httpd binary is on the system PATH, the tests will find it + automatically. If httpd is installed in a non-standard location, set + the "test.httpd.path" property in your build.properties file to point + to the httpd binary: + + test.httpd.path=/usr/sbin/httpd + + 3. Running the tests + Integration tests are excluded from the default test run. To run + them, use the integration test profile: + + ant test -Dtest.profile=integration + + The httpd integration tests are located in: + + test/org/apache/tomcat/integration/httpd/ + + 4. How the tests work + + Each test starts an httpd instance in foreground mode (-X) with a + generated configuration that proxies requests to a Tomcat instance + managed by the test framework. A file lock (httpd-binary.lock) is + used to serialize test classes so that only one httpd instance runs + at a time. + + The base class (HttpdIntegrationBaseTest) handles platform-specific + httpd module loading automatically. Test subclasses only need to + provide the test-specific httpd directives (proxy rules, extra + modules, etc.). + + (8) Source code checks (8.1) Checkstyle diff --git a/build.xml b/build.xml index 14d2960f0528..31fe59cb9a45 100644 --- a/build.xml +++ b/build.xml @@ -222,6 +222,10 @@ + + + + @@ -2008,6 +2012,9 @@ + + + @@ -2037,6 +2044,11 @@ + + + + + @@ -2119,6 +2131,22 @@ + + + + + + + + + + + + + + + + @@ -2176,6 +2204,7 @@ + @@ -2205,6 +2234,8 @@ + + diff --git a/test-profiles.properties.default b/test-profiles.properties.default index da10ee9f2e7d..4920030801c6 100644 --- a/test-profiles.properties.default +++ b/test-profiles.properties.default @@ -98,3 +98,7 @@ test.profile.tribes=**/tribes/**/*Test*.java # Build utility test profile: Tests for build tools (normally excluded) # Note: These tests depend on classes not in output JARs and are excluded by default test.profile.buildutil=**/buildutil/**/*Test*.java + +# Integration test profile: Tests requiring external processes (e.g. httpd) +# Note: These tests are excluded by default as they require external binaries +test.profile.integration=**/integration/**/Test*.java \ No newline at end of file diff --git a/test/org/apache/catalina/startup/LoggingBaseTest.java b/test/org/apache/catalina/startup/LoggingBaseTest.java index 8a96e414361b..66c5f745c7d3 100644 --- a/test/org/apache/catalina/startup/LoggingBaseTest.java +++ b/test/org/apache/catalina/startup/LoggingBaseTest.java @@ -89,7 +89,7 @@ public static File getBuildDirectory() { * that have to be deleted on cleanup, register them with * {@link #addDeleteOnTearDown(File)}. */ - public File getTemporaryDirectory() { + public static File getTemporaryDirectory() { return tempDir; } diff --git a/test/org/apache/catalina/startup/TomcatBaseTest.java b/test/org/apache/catalina/startup/TomcatBaseTest.java index 21f744647a5c..10631c5af69c 100644 --- a/test/org/apache/catalina/startup/TomcatBaseTest.java +++ b/test/org/apache/catalina/startup/TomcatBaseTest.java @@ -22,6 +22,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.io.PrintWriter; +import java.io.Serial; import java.net.HttpURLConnection; import java.net.InetAddress; import java.net.URI; @@ -466,6 +467,7 @@ public static RequestDescriptor parse(String body) { */ public static final class SnoopServlet extends HttpServlet { + @Serial private static final long serialVersionUID = 1L; @Override @@ -481,7 +483,7 @@ public void service(HttpServletRequest request, response.setCharacterEncoding("UTF-8"); ServletContext ctx = this.getServletContext(); - HttpSession session = request.getSession(false); + HttpSession session = request.getSession("true".equals(request.getParameter("createSession"))); PrintWriter out = response.getWriter(); out.println("CONTEXT-NAME: " + ctx.getServletContextName()); @@ -558,7 +560,7 @@ public void service(HttpServletRequest request, e.hasMoreElements();) { name = e.nextElement(); value = new StringBuilder(); - String values[] = request.getParameterValues(name); + String[] values = request.getParameterValues(name); int m = values.length; for (int j = 0; j < m; j++) { value.append(values[j]); @@ -597,7 +599,7 @@ public void service(HttpServletRequest request, } int bodySize = 0; - if (Method.PUT.equals(request.getMethod())) { + if (Method.PUT.equals(request.getMethod()) || Method.POST.equals(request.getMethod())) { InputStream is = request.getInputStream(); int read = 0; byte[] buffer = new byte[8192]; diff --git a/test/org/apache/tomcat/integration/httpd/HttpdIntegrationBaseTest.java b/test/org/apache/tomcat/integration/httpd/HttpdIntegrationBaseTest.java new file mode 100644 index 000000000000..f54c2d7cee9f --- /dev/null +++ b/test/org/apache/tomcat/integration/httpd/HttpdIntegrationBaseTest.java @@ -0,0 +1,176 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tomcat.integration.httpd; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.PrintWriter; +import java.net.ServerSocket; +import java.nio.channels.FileLock; +import java.util.List; + +import org.junit.AfterClass; +import org.junit.Assume; +import org.junit.BeforeClass; + +import org.apache.catalina.Context; +import org.apache.catalina.LifecycleException; +import org.apache.catalina.Valve; +import org.apache.catalina.startup.Tomcat; +import org.apache.catalina.startup.TomcatBaseTest; +import org.apache.tomcat.util.compat.JrePlatform; +import org.apache.tomcat.util.net.TesterSupport; + +/** + * Base class for httpd integration tests. + * Manages httpd and Tomcat process lifecycle. + */ +public abstract class HttpdIntegrationBaseTest extends TomcatBaseTest { + + private static final String HTTPD_CONFIG = + """ + Listen %{HTTPD_PORT} + PidFile %{CONF_DIR}/httpd.pid + LoadModule authz_core_module modules/mod_authz_core.so + """ + + (JrePlatform.IS_WINDOWS ? + """ + ErrorLog "|C:/Windows/System32/more.com" + """ + : + """ + LoadModule unixd_module modules/mod_unixd.so + LoadModule mpm_event_module modules/mod_mpm_event.so + ErrorLog /dev/stderr + """ + ) + + """ + LogLevel warn + ServerName localhost:%{HTTPD_PORT} + """; + + private static final String SERVLET_NAME = "snoop"; + + private static final File lockFile = new File("test/org/apache/tomcat/integration/httpd/httpd-binary.lock"); + private static FileLock lock = null; + + private TesterHttpd httpd; + private int httpdPort; + private int httpdSslPort; + protected File httpdConfDir; + + private int tomcatPort; + + protected abstract String getHttpdConfig(); + protected abstract List getValveConfig(); + + @BeforeClass + public static void obtainHttpdBinaryLock() throws IOException { + @SuppressWarnings("resource") + FileOutputStream fos = new FileOutputStream(lockFile); + lock = fos.getChannel().lock(); + } + + @AfterClass + public static void releaseHttpdBinaryLock() throws IOException { + // Should not be null be in case obtaining the lock fails, avoid a second error. + if (lock != null) { + lock.release(); + } + } + + @Override + public void setUp() throws Exception { + super.setUp(); + setUpTomcat(); + setUpHttpd(); + } + + @Override + public void tearDown() throws Exception { + if (httpd != null) { + httpd.stop(); + httpd = null; + } + super.tearDown(); + } + + private void setUpTomcat() throws LifecycleException { + Tomcat tomcat = getTomcatInstance(); + Context ctx = getProgrammaticRootContext(); + for (Valve valve : getValveConfig()) { + ctx.getPipeline().addValve(valve); + } + Tomcat.addServlet(ctx, SERVLET_NAME, new SnoopServlet()); + ctx.addServletMappingDecoded("/" + SERVLET_NAME, SERVLET_NAME); + tomcat.start(); + tomcatPort = getPort(); + } + + private void setUpHttpd() throws IOException { + httpdPort = findFreePort(); + httpdSslPort = findFreePort(); + httpdConfDir = getTemporaryDirectory(); + generateHttpdConfig(getHttpdConfig()); + + httpd = new TesterHttpd(httpdConfDir, httpdPort); + try { + httpd.start(); + } catch (IOException | InterruptedException ioe) { + httpd = null; + } catch (IllegalStateException ise) { + httpd = null; + Assume.assumeFalse("Required httpd module not available", ise.getMessage() != null && ise.getMessage().contains("Cannot load modules")); + throw ise; + } + } + + private static int findFreePort() throws IOException { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + public void generateHttpdConfig(String httpdConf) throws IOException { + + httpdConf = HTTPD_CONFIG + httpdConf; + + httpdConf = httpdConf.replace("%{HTTPD_PORT}", Integer.toString(httpdPort)) + .replace("%{TOMCAT_PORT}", Integer.toString(tomcatPort)) + .replace("%{SERVLET_NAME}", SERVLET_NAME) + .replace("%{CONF_DIR}", httpdConfDir.getAbsolutePath()) + .replace("%{HTTPD_SSL_PORT}", Integer.toString(httpdSslPort)) + .replace("%{SSL_CERT_FILE}", new File(TesterSupport.LOCALHOST_RSA_CERT_PEM).getAbsolutePath()) + .replace("%{SSL_KEY_FILE}", new File(TesterSupport.LOCALHOST_RSA_KEY_PEM).getAbsolutePath()) + .replace("%{SSL_CA_CERT_FILE}", new File(TesterSupport.CA_CERT_PEM).getAbsolutePath()); + + try (PrintWriter writer = new PrintWriter(new File(httpdConfDir, "httpd.conf"))) { + writer.write(httpdConf); + } + + } + + public int getHttpdPort() { + return httpdPort; + } + + public int getHttpdSslPort() { + return httpdSslPort; + } +} diff --git a/test/org/apache/tomcat/integration/httpd/TestBasicProxy.java b/test/org/apache/tomcat/integration/httpd/TestBasicProxy.java new file mode 100644 index 000000000000..e27c73b79dc0 --- /dev/null +++ b/test/org/apache/tomcat/integration/httpd/TestBasicProxy.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tomcat.integration.httpd; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.servlet.http.HttpServletResponse; + +import org.junit.Assert; +import org.junit.Test; + +import org.apache.catalina.Valve; +import org.apache.tomcat.util.buf.ByteChunk; + +public class TestBasicProxy extends HttpdIntegrationBaseTest { + + private static final String HTTPD_CONFIG = """ + LoadModule proxy_module modules/mod_proxy.so + LoadModule proxy_http_module modules/mod_proxy_http.so + LoadModule headers_module modules/mod_headers.so + ProxyRequests Off + ProxyPreserveHost On + ProxyPass /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME} + ProxyPassReverse /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME} + RequestHeader set X-Forwarded-For 140.211.11.130 + RequestHeader set X-Forwarded-Proto "https" + """; + + @Override + protected List getValveConfig() { + return new ArrayList<>(); + } + + @Override + protected String getHttpdConfig() { + return HTTPD_CONFIG; + } + + @Test + public void testBasicProxying() throws Exception { + ByteChunk res = new ByteChunk(); + int rc = getUrl("http://localhost:" + getHttpdPort() + "/endpoint", res, false); + Assert.assertEquals(HttpServletResponse.SC_OK, rc); + RequestDescriptor requestDesc = SnoopResult.parse(res.toString()); + + Assert.assertNotNull(requestDesc.getRequestInfo()); + Assert.assertEquals("127.0.0.1", requestDesc.getRequestInfo("REQUEST-REMOTE-ADDR")); + Assert.assertEquals(getHttpdPort(), Integer.valueOf(requestDesc.getRequestInfo("REQUEST-SERVER-PORT")).intValue()); + Assert.assertEquals(getPort(), Integer.valueOf(requestDesc.getRequestInfo("REQUEST-LOCAL-PORT")).intValue()); + // httpd sets X-Forwarded-Proto: https, but without RemoteIpValve Tomcat does not process it. + Assert.assertEquals("http", requestDesc.getRequestInfo("REQUEST-SCHEME")); + Assert.assertEquals("false", requestDesc.getRequestInfo("REQUEST-IS-SECURE")); + Assert.assertNotNull(requestDesc.getHeaders()); + Assert.assertNotNull(requestDesc.getHeader("X-Forwarded-For")); + Assert.assertEquals("https", requestDesc.getHeader("X-Forwarded-Proto")); + } +} diff --git a/test/org/apache/tomcat/integration/httpd/TestChunkedTransferEncodingWithProxy.java b/test/org/apache/tomcat/integration/httpd/TestChunkedTransferEncodingWithProxy.java new file mode 100644 index 000000000000..f051a4ad2c0d --- /dev/null +++ b/test/org/apache/tomcat/integration/httpd/TestChunkedTransferEncodingWithProxy.java @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tomcat.integration.httpd; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import jakarta.servlet.http.HttpServletResponse; + +import org.junit.Assert; +import org.junit.Test; + +import org.apache.catalina.Valve; +import org.apache.catalina.startup.BytesStreamer; +import org.apache.tomcat.util.buf.ByteChunk; + +public class TestChunkedTransferEncodingWithProxy extends HttpdIntegrationBaseTest { + + private static final int PAYLOAD_SIZE = 10 * 1024 * 1024 * 100; + + private static final String HTTPD_CONFIG = """ + LoadModule env_module modules/mod_env.so \s + SetEnv proxy-sendchunked 1 + LoadModule proxy_module modules/mod_proxy.so + LoadModule proxy_http_module modules/mod_proxy_http.so + ProxyPass /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME} + ProxyPassReverse /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME} + """; + + @Override + protected List getValveConfig() { + return new ArrayList<>(); + } + + @Override + protected String getHttpdConfig() { + return HTTPD_CONFIG; + } + + /** + * Verify that chunked transfer encoding works correctly through the httpd reverse proxy + * which sets proxy-sendchunked to minimize resource usage by using chunked encoding. + */ + @Test + public void testChunkedTransferEncoding() throws Exception { + byte[] payload = new byte[PAYLOAD_SIZE]; + Arrays.fill(payload, (byte) 'A'); + + BytesStreamer streamer = new BytesStreamer() { + private boolean sent = false; + + @Override + public int getLength() { + return -1; + } + + @Override + public int available() { + return sent ? 0 : payload.length; + } + + @Override + public byte[] next() { + sent = true; + return payload; + } + }; + + ByteChunk res = new ByteChunk(); + Map> reqHead = new HashMap<>(); + reqHead.put("Content-Type", List.of("application/octet-stream")); + int rc = postUrl(true, streamer, "http://localhost:" + getHttpdPort() + "/endpoint", res, reqHead, null); + Assert.assertEquals(HttpServletResponse.SC_OK, rc); + + RequestDescriptor requestDesc = SnoopResult.parse(res.toString()); + Assert.assertEquals(String.valueOf(PAYLOAD_SIZE), requestDesc.getRequestInfo("REQUEST-BODY-SIZE")); + } +} diff --git a/test/org/apache/tomcat/integration/httpd/TestErrorHandling.java b/test/org/apache/tomcat/integration/httpd/TestErrorHandling.java new file mode 100644 index 000000000000..f6601ccc05ee --- /dev/null +++ b/test/org/apache/tomcat/integration/httpd/TestErrorHandling.java @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tomcat.integration.httpd; + +import java.io.Serial; +import java.util.ArrayList; +import java.util.List; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.junit.Assert; +import org.junit.Test; + +import org.apache.catalina.Context; +import org.apache.catalina.Valve; +import org.apache.catalina.startup.Tomcat; +import org.apache.tomcat.util.buf.ByteChunk; + +public class TestErrorHandling extends HttpdIntegrationBaseTest { + + private static final String HTTPD_CONFIG = """ + LoadModule proxy_module modules/mod_proxy.so + LoadModule proxy_http_module modules/mod_proxy_http.so + ProxyRequests Off + ProxyPreserveHost On + ProxyPass / http://localhost:%{TOMCAT_PORT}/ + ProxyPassReverse / http://localhost:%{TOMCAT_PORT}/ + """; + + @Override + protected List getValveConfig() { + return new ArrayList<>(); + } + + @Override + protected String getHttpdConfig() { + return HTTPD_CONFIG; + } + + @Test + public void test404NotFound() throws Exception { + int rc = getUrl("http://localhost:" + getHttpdPort() + "/nonexistent", new ByteChunk(), false); + Assert.assertEquals(HttpServletResponse.SC_NOT_FOUND, rc); + } + + @Test + public void test500InternalError() throws Exception { + Context ctx = (Context) getTomcatInstance().getHost().findChildren()[0]; + Tomcat.addServlet(ctx, "error", new HttpServlet() { + @Serial + private static final long serialVersionUID = 1L; + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException { + throw new ServletException("Expected error"); + } + }); + ctx.addServletMappingDecoded("/error", "error"); + int rc = getUrl("http://localhost:" + getHttpdPort() + "/error", new ByteChunk(), false); + Assert.assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, rc); + } + +} diff --git a/test/org/apache/tomcat/integration/httpd/TestFullReverseProxy.java b/test/org/apache/tomcat/integration/httpd/TestFullReverseProxy.java new file mode 100644 index 000000000000..829bd500cce9 --- /dev/null +++ b/test/org/apache/tomcat/integration/httpd/TestFullReverseProxy.java @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tomcat.integration.httpd; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.servlet.http.HttpServletResponse; + +import org.junit.Assert; +import org.junit.Test; + +import org.apache.catalina.Globals; +import org.apache.catalina.Valve; +import org.apache.catalina.valves.RemoteIpValve; +import org.apache.catalina.valves.SSLValve; +import org.apache.tomcat.util.buf.ByteChunk; +import org.apache.tomcat.util.net.TesterSupport; + +public class TestFullReverseProxy extends HttpdIntegrationBaseTest { + private static final String HTTPD_CONFIG = """ + LoadModule proxy_module modules/mod_proxy.so + LoadModule proxy_http_module modules/mod_proxy_http.so + LoadModule headers_module modules/mod_headers.so + LoadModule ssl_module modules/mod_ssl.so + SSLSessionCache none + Listen %{HTTPD_SSL_PORT} https + + ServerName localhost:%{HTTPD_SSL_PORT} + SSLEngine on + SSLCertificateFile "%{SSL_CERT_FILE}" + SSLCertificateKeyFile "%{SSL_KEY_FILE}" + ProxyRequests Off + ProxyPass /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME} + ProxyPassReverse /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME} + RequestHeader set SSL_CLIENT_CERT "%{SSL_CLIENT_CERT}s" + RequestHeader set SSL_CIPHER "%{SSL_CIPHER}s" + RequestHeader set SSL_SESSION_ID "%{SSL_SESSION_ID}s" + RequestHeader set SSL_CIPHER_USEKEYSIZE "%{SSL_CIPHER_USEKEYSIZE}s" + SSLVerifyClient optional + SSLCACertificateFile "%{SSL_CA_CERT_FILE}" + SSLOptions +ExportCertData + ProxyAddHeaders Off + RequestHeader set X-Forwarded-For 140.211.11.130 + RequestHeader set X-Forwarded-Proto https + RequestHeader set X-Forwarded-Host whoamI.tomcat + + """; + + @Override + protected List getValveConfig() { + List valves = new ArrayList<>(); + + RemoteIpValve remoteIpValve = new RemoteIpValve(); + remoteIpValve.setHostHeader("X-Forwarded-Host"); + valves.add(remoteIpValve); + + SSLValve sslValve = new SSLValve(); + valves.add(sslValve); + + return valves; + } + + @Override + protected String getHttpdConfig() { + return HTTPD_CONFIG; + } + + @Test + public void testFullReverseProxying() throws Exception { + TesterSupport.configureClientSsl(); + + ByteChunk res = new ByteChunk(); + int rc = getUrl("https://localhost:" + getHttpdSslPort() + "/endpoint", res, false); + Assert.assertEquals(HttpServletResponse.SC_OK, rc); + RequestDescriptor requestDesc = SnoopResult.parse(res.toString()); + + Assert.assertNotNull(requestDesc.getRequestInfo()); + Assert.assertEquals("140.211.11.130", requestDesc.getRequestInfo("REQUEST-REMOTE-ADDR")); + Assert.assertEquals(443, Integer.valueOf(requestDesc.getRequestInfo("REQUEST-SERVER-PORT")).intValue()); + Assert.assertEquals("https", requestDesc.getRequestInfo("REQUEST-SCHEME")); + Assert.assertEquals("true", requestDesc.getRequestInfo("REQUEST-IS-SECURE")); + Assert.assertEquals("whoamI.tomcat", requestDesc.getRequestInfo("REQUEST-SERVER-NAME")); + + Assert.assertNotNull(requestDesc.getHeaders()); + Assert.assertNull(requestDesc.getHeader("X-Forwarded-For")); + Assert.assertEquals("https", requestDesc.getHeader("X-Forwarded-Proto")); + + + Assert.assertNotNull(requestDesc.getAttributes()); + Assert.assertNotNull(requestDesc.getAttribute(Globals.CIPHER_SUITE_ATTR)); + Assert.assertNotNull(requestDesc.getAttribute(Globals.SSL_SESSION_ID_ATTR)); + Assert.assertNotNull(requestDesc.getAttribute(Globals.KEY_SIZE_ATTR)); + Assert.assertNotNull(requestDesc.getAttribute(Globals.CERTIFICATES_ATTR)); + } +} diff --git a/test/org/apache/tomcat/integration/httpd/TestLargePayloadWithProxy.java b/test/org/apache/tomcat/integration/httpd/TestLargePayloadWithProxy.java new file mode 100644 index 000000000000..992d0ce6dd2c --- /dev/null +++ b/test/org/apache/tomcat/integration/httpd/TestLargePayloadWithProxy.java @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tomcat.integration.httpd; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import jakarta.servlet.http.HttpServletResponse; + +import org.junit.Assert; +import org.junit.Test; + +import org.apache.catalina.Valve; +import org.apache.catalina.startup.BytesStreamer; +import org.apache.tomcat.util.buf.ByteChunk; + +public class TestLargePayloadWithProxy extends HttpdIntegrationBaseTest { + + private static final int PAYLOAD_SIZE = 10 * 1024 * 1024; + + private static final String HTTPD_CONFIG = """ + LoadModule proxy_module modules/mod_proxy.so + LoadModule proxy_http_module modules/mod_proxy_http.so + ProxyPass /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME} + ProxyPassReverse /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME} + """; + + @Override + protected List getValveConfig() { + return new ArrayList<>(); + } + + @Override + protected String getHttpdConfig() { + return HTTPD_CONFIG; + } + + /** + * Verify that a large POST body passes through the httpd reverse proxy + */ + @Test + public void testLargePostBody() throws Exception { + byte[] payload = new byte[PAYLOAD_SIZE]; + Arrays.fill(payload, (byte) 'A'); + + ByteChunk res = new ByteChunk(); + Map> reqHead = new HashMap<>(); + reqHead.put("Content-Type", List.of("application/octet-stream")); + int rc = postUrl(payload, "http://localhost:" + getHttpdPort() + "/endpoint", res, reqHead, null); + Assert.assertEquals(HttpServletResponse.SC_OK, rc); + + RequestDescriptor requestDesc = SnoopResult.parse(res.toString()); + Assert.assertEquals(String.valueOf(PAYLOAD_SIZE), requestDesc.getRequestInfo("REQUEST-BODY-SIZE")); + } + + /** + * Verify that chunked transfer encoding works correctly through the httpd reverse proxy. + */ + @Test + public void testChunkedTransferEncoding() throws Exception { + byte[] payload = new byte[PAYLOAD_SIZE]; + Arrays.fill(payload, (byte) 'A'); + + BytesStreamer streamer = new BytesStreamer() { + private boolean sent = false; + + @Override + public int getLength() { + return -1; + } + + @Override + public int available() { + return sent ? 0 : payload.length; + } + + @Override + public byte[] next() { + sent = true; + return payload; + } + }; + + ByteChunk res = new ByteChunk(); + Map> reqHead = new HashMap<>(); + reqHead.put("Content-Type", List.of("application/octet-stream")); + int rc = postUrl(true, streamer, "http://localhost:" + getHttpdPort() + "/endpoint", res, reqHead, null); + Assert.assertEquals(HttpServletResponse.SC_OK, rc); + + RequestDescriptor requestDesc = SnoopResult.parse(res.toString()); + Assert.assertEquals(String.valueOf(PAYLOAD_SIZE), requestDesc.getRequestInfo("REQUEST-BODY-SIZE")); + } +} diff --git a/test/org/apache/tomcat/integration/httpd/TestRemoteIpValveWithProxy.java b/test/org/apache/tomcat/integration/httpd/TestRemoteIpValveWithProxy.java new file mode 100644 index 000000000000..24dfe2fc1c97 --- /dev/null +++ b/test/org/apache/tomcat/integration/httpd/TestRemoteIpValveWithProxy.java @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tomcat.integration.httpd; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.servlet.http.HttpServletResponse; + +import org.junit.Assert; +import org.junit.Test; + +import org.apache.catalina.Valve; +import org.apache.catalina.valves.RemoteIpValve; +import org.apache.tomcat.util.buf.ByteChunk; + +public class TestRemoteIpValveWithProxy extends HttpdIntegrationBaseTest { + private static final String HTTPD_CONFIG = """ + LoadModule proxy_module modules/mod_proxy.so + LoadModule proxy_http_module modules/mod_proxy_http.so + LoadModule headers_module modules/mod_headers.so + ProxyRequests Off + ProxyPreserveHost On + ProxyPass /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME} + ProxyPassReverse /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME} + ProxyAddHeaders Off + RequestHeader set X-Forwarded-For 140.211.11.130 + RequestHeader set X-Forwarded-Proto https + RequestHeader set X-Forwarded-Host whoamI.tomcat + """; + + @Override + protected List getValveConfig() { + List valves = new ArrayList<>(); + + RemoteIpValve remoteIpValve = new RemoteIpValve(); + remoteIpValve.setHostHeader("X-Forwarded-Host"); + valves.add(remoteIpValve); + + return valves; + } + + @Override + protected String getHttpdConfig() { + return HTTPD_CONFIG; + } + + @Test + public void testRemoteIpValveProxying() throws Exception { + ByteChunk res = new ByteChunk(); + int rc = getUrl("http://localhost:" + getHttpdPort() + "/endpoint", res, false); + Assert.assertEquals(HttpServletResponse.SC_OK, rc); + RequestDescriptor requestDesc = SnoopResult.parse(res.toString()); + + Assert.assertNotNull(requestDesc.getRequestInfo()); + Assert.assertEquals("140.211.11.130", requestDesc.getRequestInfo("REQUEST-REMOTE-ADDR")); + Assert.assertEquals(443, Integer.valueOf(requestDesc.getRequestInfo("REQUEST-SERVER-PORT")).intValue()); + Assert.assertEquals(getPort(), Integer.valueOf(requestDesc.getRequestInfo("REQUEST-LOCAL-PORT")).intValue()); + Assert.assertEquals("https", requestDesc.getRequestInfo("REQUEST-SCHEME")); + Assert.assertEquals("true", requestDesc.getRequestInfo("REQUEST-IS-SECURE")); + Assert.assertEquals("whoamI.tomcat", requestDesc.getRequestInfo("REQUEST-SERVER-NAME")); + Assert.assertNotNull(requestDesc.getHeaders()); + Assert.assertNull(requestDesc.getHeader("X-Forwarded-For")); + Assert.assertEquals("https", requestDesc.getHeader("X-Forwarded-Proto")); + } +} diff --git a/test/org/apache/tomcat/integration/httpd/TestSSLValveWithProxy01.java b/test/org/apache/tomcat/integration/httpd/TestSSLValveWithProxy01.java new file mode 100644 index 000000000000..65e2b883d979 --- /dev/null +++ b/test/org/apache/tomcat/integration/httpd/TestSSLValveWithProxy01.java @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tomcat.integration.httpd; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.servlet.http.HttpServletResponse; + +import org.junit.Assert; +import org.junit.Test; + +import org.apache.catalina.Globals; +import org.apache.catalina.Valve; +import org.apache.catalina.valves.SSLValve; +import org.apache.tomcat.util.buf.ByteChunk; +import org.apache.tomcat.util.net.TesterSupport; + +public class TestSSLValveWithProxy01 extends HttpdIntegrationBaseTest { + private static final String HTTPD_CONFIG = """ + LoadModule proxy_module modules/mod_proxy.so + LoadModule proxy_http_module modules/mod_proxy_http.so + LoadModule headers_module modules/mod_headers.so + LoadModule ssl_module modules/mod_ssl.so + SSLSessionCache none + Listen %{HTTPD_SSL_PORT} https + + ServerName localhost:%{HTTPD_SSL_PORT} + SSLEngine on + SSLCertificateFile "%{SSL_CERT_FILE}" + SSLCertificateKeyFile "%{SSL_KEY_FILE}" + ProxyRequests Off + ProxyPass /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME} + ProxyPassReverse /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME} + RequestHeader set SSL_CLIENT_CERT "%{SSL_CLIENT_CERT}s" + RequestHeader set SSL_CIPHER "%{SSL_CIPHER}s" + RequestHeader set SSL_SESSION_ID "%{SSL_SESSION_ID}s" + RequestHeader set SSL_CIPHER_USEKEYSIZE "%{SSL_CIPHER_USEKEYSIZE}s" + + """; + + @Override + protected List getValveConfig() { + List valves = new ArrayList<>(); + + SSLValve sslValve = new SSLValve(); + valves.add(sslValve); + + return valves; + } + + @Override + protected String getHttpdConfig() { + return HTTPD_CONFIG; + } + + @Test + public void testSSLValveProxying() throws Exception { + TesterSupport.configureClientSsl(); + + ByteChunk res = new ByteChunk(); + int rc = getUrl("https://localhost:" + getHttpdSslPort() + "/endpoint", res, false); + Assert.assertEquals(HttpServletResponse.SC_OK, rc); + RequestDescriptor requestDesc = SnoopResult.parse(res.toString()); + + Assert.assertNotNull(requestDesc.getAttributes()); + Assert.assertNotNull(requestDesc.getAttribute(Globals.CIPHER_SUITE_ATTR)); + Assert.assertNotNull(requestDesc.getAttribute(Globals.SSL_SESSION_ID_ATTR)); + Assert.assertNotNull(requestDesc.getAttribute(Globals.KEY_SIZE_ATTR)); + // No client certificate in this test, mod_ssl sends null which SSLValve correctly treats as absent. + Assert.assertNull(requestDesc.getAttribute(Globals.CERTIFICATES_ATTR)); + } +} diff --git a/test/org/apache/tomcat/integration/httpd/TestSSLValveWithProxy02.java b/test/org/apache/tomcat/integration/httpd/TestSSLValveWithProxy02.java new file mode 100644 index 000000000000..b1ee23803f4f --- /dev/null +++ b/test/org/apache/tomcat/integration/httpd/TestSSLValveWithProxy02.java @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tomcat.integration.httpd; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.servlet.http.HttpServletResponse; + +import org.junit.Assert; +import org.junit.Test; + +import org.apache.catalina.Globals; +import org.apache.catalina.Valve; +import org.apache.catalina.valves.SSLValve; +import org.apache.tomcat.util.buf.ByteChunk; +import org.apache.tomcat.util.net.TesterSupport; + +public class TestSSLValveWithProxy02 extends HttpdIntegrationBaseTest { + private static final String HTTPD_CONFIG = """ + LoadModule proxy_module modules/mod_proxy.so + LoadModule proxy_http_module modules/mod_proxy_http.so + LoadModule headers_module modules/mod_headers.so + LoadModule ssl_module modules/mod_ssl.so + SSLSessionCache none + Listen %{HTTPD_SSL_PORT} https + + ServerName localhost:%{HTTPD_SSL_PORT} + SSLEngine on + SSLCertificateFile "%{SSL_CERT_FILE}" + SSLCertificateKeyFile "%{SSL_KEY_FILE}" + ProxyRequests Off + ProxyPass /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME} + ProxyPassReverse /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME} + RequestHeader set SSL_CLIENT_CERT "%{SSL_CLIENT_CERT}s" + RequestHeader set SSL_CIPHER "%{SSL_CIPHER}s" + RequestHeader set SSL_SESSION_ID "%{SSL_SESSION_ID}s" + RequestHeader set SSL_CIPHER_USEKEYSIZE "%{SSL_CIPHER_USEKEYSIZE}s" + SSLVerifyClient optional + SSLCACertificateFile "%{SSL_CA_CERT_FILE}" + SSLOptions +ExportCertData + + """; + + @Override + protected List getValveConfig() { + List valves = new ArrayList<>(); + + SSLValve sslValve = new SSLValve(); + valves.add(sslValve); + + return valves; + } + + @Override + protected String getHttpdConfig() { + return HTTPD_CONFIG; + } + + @Test + public void testSSLValveProxying() throws Exception { + TesterSupport.configureClientSsl(); + + ByteChunk res = new ByteChunk(); + int rc = getUrl("https://localhost:" + getHttpdSslPort() + "/endpoint", res, false); + Assert.assertEquals(HttpServletResponse.SC_OK, rc); + RequestDescriptor requestDesc = SnoopResult.parse(res.toString()); + + Assert.assertNotNull(requestDesc.getAttribute(Globals.CERTIFICATES_ATTR)); + } +} diff --git a/test/org/apache/tomcat/integration/httpd/TestSessionWithProxy.java b/test/org/apache/tomcat/integration/httpd/TestSessionWithProxy.java new file mode 100644 index 000000000000..a021b741a2c9 --- /dev/null +++ b/test/org/apache/tomcat/integration/httpd/TestSessionWithProxy.java @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tomcat.integration.httpd; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import jakarta.servlet.http.HttpServletResponse; + +import org.junit.Assert; +import org.junit.Test; + +import org.apache.catalina.Valve; +import org.apache.catalina.valves.RemoteIpValve; +import org.apache.tomcat.util.buf.ByteChunk; +import org.apache.tomcat.util.net.TesterSupport; + +public class TestSessionWithProxy extends HttpdIntegrationBaseTest { + private static final String HTTPD_CONFIG = """ + LoadModule proxy_module modules/mod_proxy.so + LoadModule proxy_http_module modules/mod_proxy_http.so + LoadModule headers_module modules/mod_headers.so + LoadModule ssl_module modules/mod_ssl.so + SSLSessionCache none + ProxyPass /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME} + ProxyPassReverse /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME} + Listen %{HTTPD_SSL_PORT} https + + ServerName localhost:%{HTTPD_SSL_PORT} + SSLEngine on + SSLCertificateFile "%{SSL_CERT_FILE}" + SSLCertificateKeyFile "%{SSL_KEY_FILE}" + ProxyPass /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME} + ProxyPassReverse /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME} + RequestHeader set X-Forwarded-Proto https + + """; + + @Override + protected List getValveConfig() { + List valves = new ArrayList<>(); + + RemoteIpValve remoteIpValve = new RemoteIpValve(); + valves.add(remoteIpValve); + + return valves; + } + + @Override + protected String getHttpdConfig() { + return HTTPD_CONFIG; + } + + /** + * Verify that a session created through httpd can be retrieved + * on a subsequent request using the session cookie. + */ + @Test + public void testSessionCookieSetAndRetrieved() throws Exception { + // Create a session + ByteChunk res = new ByteChunk(); + Map> resHead = new HashMap<>(); + int rc = getUrl("http://localhost:" + getHttpdPort() + "/endpoint?createSession=true", res, null, resHead); + Assert.assertEquals(HttpServletResponse.SC_OK, rc); + + RequestDescriptor requestDesc = SnoopResult.parse(res.toString()); + Assert.assertNotNull(requestDesc.getRequestInfo()); + String sessionId = requestDesc.getRequestInfo("SESSION-ID"); + Assert.assertNotNull(sessionId); + Assert.assertEquals("true", requestDesc.getRequestInfo("SESSION-IS-NEW")); + + String setCookie = resHead.get("Set-Cookie").getFirst(); + Assert.assertTrue(setCookie.contains("JSESSIONID")); + + // Send the session cookie back + Map> reqHead = new HashMap<>(); + reqHead.put("Cookie", List.of("JSESSIONID=" + sessionId)); + rc = getUrl("http://localhost:" + getHttpdPort() + "/endpoint", res, reqHead, null); + Assert.assertEquals(HttpServletResponse.SC_OK, rc); + + requestDesc = SnoopResult.parse(res.toString()); + Assert.assertNotNull(requestDesc.getRequestInfo()); + Assert.assertEquals(sessionId, requestDesc.getRequestInfo("SESSION-ID")); + Assert.assertEquals("false", requestDesc.getRequestInfo("SESSION-IS-NEW")); + } + + /** + * Verify that when SSL is used at httpd, but not Tomcat, and RemoteIpValve + * sets the scheme to https, session cookies have the Secure flag. + */ + @Test + public void testSecureCookieWithSslTermination() throws Exception { + TesterSupport.configureClientSsl(); + ByteChunk res = new ByteChunk(); + Map> resHead = new HashMap<>(); + getUrl("https://localhost:" + getHttpdSslPort() + "/endpoint?createSession=true", res, null, resHead); + Assert.assertTrue("Session cookie should have Secure flag", resHead.get("Set-Cookie").getFirst().contains("Secure")); + } + +} diff --git a/test/org/apache/tomcat/integration/httpd/TesterHttpd.java b/test/org/apache/tomcat/integration/httpd/TesterHttpd.java new file mode 100644 index 000000000000..691dd428cd11 --- /dev/null +++ b/test/org/apache/tomcat/integration/httpd/TesterHttpd.java @@ -0,0 +1,149 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tomcat.integration.httpd; + +import java.io.File; +import java.io.IOException; +import java.io.PrintStream; +import java.io.Reader; +import java.net.Socket; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.junit.Assert; + + +public class TesterHttpd { + + private final File httpdConfDir; + private final int httpdPort; + + private static final String HTTPD_PATH = "tomcat.test.httpd.path"; + + private Process p; + + public TesterHttpd(File httpdConfDir, int httpdPort) { + this.httpdConfDir = httpdConfDir; + this.httpdPort = httpdPort; + } + + public void start() throws IOException, InterruptedException { + start(false); + } + + public void start(boolean swallowOutput) throws IOException, InterruptedException { + if (p != null) { + throw new IllegalStateException("Already started"); + } + + String httpdPath = System.getProperty(HTTPD_PATH); + if (httpdPath == null || httpdPath.isEmpty()) { + httpdPath = "httpd"; + } + + File httpdConfFile = new File(httpdConfDir, "httpd.conf"); + validateHttpdConfig(httpdPath, httpdConfFile.getAbsolutePath()); + + List cmd = new ArrayList<>(4); + cmd.add(httpdPath); + cmd.add("-f"); + cmd.add(httpdConfFile.getAbsolutePath()); + cmd.add("-X"); + + ProcessBuilder pb = new ProcessBuilder(cmd.toArray(new String[0])); + + p = pb.start(); + + redirect(p.inputReader(), System.out, swallowOutput); + redirect(p.errorReader(), System.err, swallowOutput); + + Assert.assertTrue(p.isAlive() && isHttpdReady()); + } + + public void stop() { + if (p == null) { + throw new IllegalStateException("Not started"); + } + p.destroy(); + + try { + if (!p.waitFor(30, TimeUnit.SECONDS)) { + throw new IllegalStateException("Failed to stop"); + } + } catch (InterruptedException e) { + throw new IllegalStateException("Interrupted while waiting to stop", e); + } + } + + private void redirect(final Reader r, final PrintStream os, final boolean swallow) { + /* + * InputStream will close when process ends. Thread will exit once stream closes. + */ + new Thread( () -> { + char[] cbuf = new char[1024]; + try { + int read; + while ((read = r.read(cbuf)) > 0) { + if (!swallow) { + os.print(new String(cbuf, 0, read)); + } + } + } catch (IOException ignore) { + // Ignore + } + + }).start(); + } + + private static void validateHttpdConfig(final String httpdPath, final String httpdConfPath) throws IOException, InterruptedException { + List cmd = new ArrayList<>(4); + + cmd.add(httpdPath); + cmd.add("-t"); + cmd.add("-f"); + cmd.add(httpdConfPath); + + ProcessBuilder pb = new ProcessBuilder(cmd.toArray(new String[0])); + pb.redirectErrorStream(true); + + Process p = pb.start(); + + String output = new String(p.getInputStream().readAllBytes()); + int exitCode = p.waitFor(); + + if (exitCode != 0) { + throw new IllegalStateException("Httpd configuration invalid. Output: " + output); + } + } + + @SuppressWarnings("BusyWait") + private boolean isHttpdReady() throws InterruptedException { + long deadline = System.currentTimeMillis() + 1000; + while (System.currentTimeMillis() < deadline) { + try (Socket ignored = new Socket("localhost", this.httpdPort)) { + return true; + } catch (IOException e) { + Thread.sleep(100); + } + } + throw new IllegalStateException("Httpd has not been started."); + } + + +} diff --git a/test/org/apache/tomcat/integration/httpd/httpd-binary.lock b/test/org/apache/tomcat/integration/httpd/httpd-binary.lock new file mode 100644 index 000000000000..e69de29bb2d1