diff --git a/artemis-dto/src/main/java/org/apache/activemq/artemis/dto/BindingDTO.java b/artemis-dto/src/main/java/org/apache/activemq/artemis/dto/BindingDTO.java index 261351a06f..64cb3b5f1e 100644 --- a/artemis-dto/src/main/java/org/apache/activemq/artemis/dto/BindingDTO.java +++ b/artemis-dto/src/main/java/org/apache/activemq/artemis/dto/BindingDTO.java @@ -81,6 +81,9 @@ public class BindingDTO { @XmlAttribute private Boolean sniRequired; + @XmlAttribute + private Boolean sslAutoReload; + public String getKeyStorePassword() throws Exception { return getPassword(this.keyStorePassword); } @@ -225,6 +228,14 @@ public class BindingDTO { this.sniRequired = sniRequired; } + public Boolean getSslAutoReload() { + return sslAutoReload; + } + + public void setSslAutoReload(Boolean sslAutoReload) { + this.sslAutoReload = sslAutoReload; + } + public BindingDTO() { apps = new ArrayList<>(); } diff --git a/artemis-dto/src/main/java/org/apache/activemq/artemis/dto/WebServerDTO.java b/artemis-dto/src/main/java/org/apache/activemq/artemis/dto/WebServerDTO.java index 91f12ad9b5..8f8bbeaeef 100644 --- a/artemis-dto/src/main/java/org/apache/activemq/artemis/dto/WebServerDTO.java +++ b/artemis-dto/src/main/java/org/apache/activemq/artemis/dto/WebServerDTO.java @@ -104,6 +104,9 @@ public class WebServerDTO extends ComponentDTO { @XmlAttribute public Integer idleThreadTimeout = 60000; + @XmlAttribute + public Integer scanPeriod; + public String getPath() { return path; } @@ -168,6 +171,14 @@ public class WebServerDTO extends ComponentDTO { this.idleThreadTimeout = idleThreadTimeout; } + public Integer getScanPeriod() { + return scanPeriod; + } + + public void setScanPeriod(Integer scanPeriod) { + this.scanPeriod = scanPeriod; + } + public List getBindings() { return bindings; } diff --git a/artemis-dto/src/test/java/org/apache/activemq/artemis/dto/test/WebServerDTOTest.java b/artemis-dto/src/test/java/org/apache/activemq/artemis/dto/test/WebServerDTOTest.java index f1e636c767..54709d04cc 100644 --- a/artemis-dto/src/test/java/org/apache/activemq/artemis/dto/test/WebServerDTOTest.java +++ b/artemis-dto/src/test/java/org/apache/activemq/artemis/dto/test/WebServerDTOTest.java @@ -49,6 +49,7 @@ public class WebServerDTOTest { Assert.assertNull(defaultBinding.getTrustStorePassword()); Assert.assertNull(defaultBinding.getSniHostCheck()); Assert.assertNull(defaultBinding.getSniRequired()); + Assert.assertNull(defaultBinding.getSslAutoReload()); } @Test diff --git a/artemis-web/pom.xml b/artemis-web/pom.xml index 9f38ddb676..8428141215 100644 --- a/artemis-web/pom.xml +++ b/artemis-web/pom.xml @@ -189,6 +189,7 @@ server-cert.pem server-key.pem server-pem-props-config.txt + other-server-keystore.p12 diff --git a/artemis-web/src/main/java/org/apache/activemq/artemis/component/WebServerComponent.java b/artemis-web/src/main/java/org/apache/activemq/artemis/component/WebServerComponent.java index 79f4fcbec5..a67096a5ee 100644 --- a/artemis-web/src/main/java/org/apache/activemq/artemis/component/WebServerComponent.java +++ b/artemis-web/src/main/java/org/apache/activemq/artemis/component/WebServerComponent.java @@ -25,7 +25,9 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.EnumSet; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import org.apache.activemq.artemis.ActiveMQWebLogger; @@ -51,8 +53,10 @@ import org.eclipse.jetty.server.handler.ContextHandler; import org.eclipse.jetty.server.handler.HandlerList; import org.eclipse.jetty.server.handler.ResourceHandler; import org.eclipse.jetty.servlet.FilterHolder; +import org.eclipse.jetty.util.Scanner; import org.eclipse.jetty.util.ssl.SslContextFactory; import org.eclipse.jetty.util.thread.QueuedThreadPool; +import org.eclipse.jetty.util.thread.ScheduledExecutorScheduler; import org.eclipse.jetty.webapp.WebAppContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -71,6 +75,10 @@ public class WebServerComponent implements ExternalComponent, WebServerComponent public static final boolean DEFAULT_SNI_REQUIRED_VALUE = false; + public static final boolean DEFAULT_SSL_AUTO_RELOAD_VALUE = false; + + public static final int DEFAULT_SCAN_PERIOD_VALUE = 5; + private Server server; private HandlerList handlers; private WebServerDTO webServerConfig; @@ -83,12 +91,23 @@ public class WebServerComponent implements ExternalComponent, WebServerComponent private String artemisInstance; private String artemisHome; + private int scanPeriod; + private Scanner scanner; + private ScheduledExecutorScheduler scannerScheduler; + private Map> scannerTasks = new HashMap<>(); + @Override public void configure(ComponentDTO config, String artemisInstance, String artemisHome) throws Exception { this.webServerConfig = (WebServerDTO) config; this.artemisInstance = artemisInstance; this.artemisHome = artemisHome; + if (webServerConfig.getScanPeriod() != null) { + scanPeriod = webServerConfig.getScanPeriod(); + } else { + scanPeriod = DEFAULT_SCAN_PERIOD_VALUE; + } + temporaryWarDir = Paths.get(artemisInstance != null ? artemisInstance : ".").resolve("tmp").resolve("webapps").toAbsolutePath(); if (!Files.exists(temporaryWarDir)) { Files.createDirectories(temporaryWarDir); @@ -258,6 +277,10 @@ public class WebServerComponent implements ExternalComponent, WebServerComponent } } } + if (Boolean.TRUE.equals(binding.getSslAutoReload())) { + addStoreResourceScannerTask(binding.getKeyStorePath(), sslFactory); + addStoreResourceScannerTask(binding.getTrustStorePath(), sslFactory); + } SslConnectionFactory sslConnectionFactory = new SslConnectionFactory(sslFactory, "HTTP/1.1"); @@ -269,7 +292,6 @@ public class WebServerComponent implements ExternalComponent, WebServerComponent HttpConnectionFactory httpFactory = new HttpConnectionFactory(httpConfiguration); connector = new ServerConnector(server, sslConnectionFactory, httpFactory); - } else { httpConfiguration.setSendServerVersion(false); ConnectionFactory connectionFactory = new HttpConnectionFactory(httpConfiguration); @@ -281,6 +303,75 @@ public class WebServerComponent implements ExternalComponent, WebServerComponent return connector; } + private File getStoreFile(String storeFilename) { + File storeFile = new File(storeFilename); + if (!storeFile.exists()) + throw new IllegalArgumentException("Store file does not exist: " + storeFilename); + if (storeFile.isDirectory()) + throw new IllegalArgumentException("Expected store file not directory: " + storeFilename); + + return storeFile; + } + + private File getParentStoreFile(File storeFile) { + File parentFile = storeFile.getParentFile(); + if (!parentFile.exists() || !parentFile.isDirectory()) + throw new IllegalArgumentException("Error obtaining store dir for " + storeFile); + + return parentFile; + } + + private Scanner getScanner() { + if (scannerScheduler == null) { + scannerScheduler = new ScheduledExecutorScheduler("WebScannerScheduler", true, 1); + server.addBean(scannerScheduler); + } + + if (scanner == null) { + scanner = new Scanner(scannerScheduler); + scanner.setScanInterval(scanPeriod); + scanner.setReportDirs(false); + scanner.setReportExistingFilesOnStartup(false); + scanner.setScanDepth(1); + scanner.addListener((Scanner.BulkListener) filenames -> { + for (String filename: filenames) { + List tasks = scannerTasks.get(filename); + if (tasks != null) { + tasks.forEach(t -> t.run()); + } + } + }); + server.addBean(scanner); + } + + return scanner; + } + + private void addScannerTask(File file, Runnable task) { + File parentFile = getParentStoreFile(file); + String storeFilename = file.toPath().toString(); + List tasks = scannerTasks.get(storeFilename); + if (tasks == null) { + tasks = new ArrayList<>(); + scannerTasks.put(storeFilename, tasks); + } + tasks.add(task); + getScanner().addDirectory(parentFile.toPath()); + } + + private void addStoreResourceScannerTask(String storeFilename, SslContextFactory.Server sslFactory) { + if (storeFilename != null) { + File storeFile = getStoreFile(storeFilename); + addScannerTask(storeFile, () -> { + try { + sslFactory.reload(f -> { }); + } catch (Exception e) { + logger.warn("Failed to reload the ssl factory related to {}", storeFile, e); + } + }); + } + } + private RequestLog getRequestLog() { RequestLogWriter requestLogWriter = new RequestLogWriter(); CustomRequestLog requestLog; @@ -413,6 +504,9 @@ public class WebServerComponent implements ExternalComponent, WebServerComponent ActiveMQWebLogger.LOGGER.stoppingEmbeddedWebServer(); server.stop(); server = null; + scanner = null; + scannerScheduler = null; + scannerTasks.clear(); cleanupWebTemporaryFiles(webContextData); webContextData.clear(); jolokiaUrls.clear(); diff --git a/artemis-web/src/test/java/org/apache/activemq/cli/test/WebServerComponentTest.java b/artemis-web/src/test/java/org/apache/activemq/cli/test/WebServerComponentTest.java index e43a204c80..46f05335f8 100644 --- a/artemis-web/src/test/java/org/apache/activemq/cli/test/WebServerComponentTest.java +++ b/artemis-web/src/test/java/org/apache/activemq/cli/test/WebServerComponentTest.java @@ -16,10 +16,12 @@ */ package org.apache.activemq.cli.test; +import javax.net.ssl.HostnameVerifier; import javax.net.ssl.SNIHostName; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSession; import javax.net.ssl.SSLSocket; import java.io.BufferedInputStream; import java.io.File; @@ -30,7 +32,9 @@ import java.net.HttpURLConnection; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.nio.file.Files; import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -38,6 +42,7 @@ import java.util.List; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.jar.Attributes; import java.util.jar.JarEntry; import java.util.jar.JarOutputStream; @@ -75,6 +80,7 @@ import org.apache.activemq.artemis.dto.BindingDTO; import org.apache.activemq.artemis.dto.BrokerDTO; import org.apache.activemq.artemis.dto.WebServerDTO; import org.apache.activemq.artemis.utils.ThreadLeakCheckRule; +import org.apache.activemq.artemis.utils.Wait; import org.apache.http.HttpException; import org.apache.http.HttpHost; import org.apache.http.client.methods.CloseableHttpResponse; @@ -97,12 +103,17 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; public class WebServerComponentTest extends Assert { @Rule public ThreadLeakCheckRule leakCheckRule = new ThreadLeakCheckRule(); + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + static final String URL = System.getProperty("url", "http://localhost:8161/WebServerComponentTest.txt"); static final String SECURE_URL = System.getProperty("url", "https://localhost:8448/WebServerComponentTest.txt"); @@ -289,6 +300,7 @@ public class WebServerComponentTest extends Assert { webServerDTO.setBindings(Collections.singletonList(bindingDTO)); webServerDTO.path = "webapps"; webServerDTO.webContentEnabled = true; + webServerDTO.setScanPeriod(1); WebServerComponent webServerComponent = new WebServerComponent(); Assert.assertFalse(webServerComponent.isStarted()); @@ -416,12 +428,67 @@ public class WebServerComponentTest extends Assert { } } + @Test + public void testSSLAutoReload() throws Exception { + File keyStoreFile = tempFolder.newFile(); + + Files.copy(WebServerComponentTest.class.getClassLoader().getResourceAsStream("server-keystore.p12"), + keyStoreFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + + BindingDTO bindingDTO = new BindingDTO(); + bindingDTO.setSslAutoReload(true); + bindingDTO.setKeyStorePath(keyStoreFile.getAbsolutePath()); + bindingDTO.setKeyStorePassword(KEY_STORE_PASSWORD); + WebServerComponent webServerComponent = startSimpleSecureServer(bindingDTO); + + try { + int port = webServerComponent.getPort(0); + AtomicReference sslSessionReference = new AtomicReference<>(); + HostnameVerifier hostnameVerifier = (s, sslSession) -> { + sslSessionReference.set(sslSession); + return true; + }; + + // check server certificate contains ActiveMQ Artemis Server + Assert.assertTrue(testSimpleSecureServer("localhost", port, "localhost", null, hostnameVerifier) == 200 && + sslSessionReference.get().getPeerCertificates()[0].toString().contains("CN=ActiveMQ Artemis Server,")); + + // check server certificate doesn't contain ActiveMQ Artemis Server + Assert.assertFalse(testSimpleSecureServer("localhost", port, "localhost", null, hostnameVerifier) == 200 && + sslSessionReference.get().getPeerCertificates()[0].toString().contains("CN=ActiveMQ Artemis Other Server,")); + + // update server keystore + Files.copy(WebServerComponentTest.class.getClassLoader().getResourceAsStream("other-server-keystore.p12"), + keyStoreFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + + // check server certificate contains ActiveMQ Artemis Other Server + Wait.assertTrue(() -> testSimpleSecureServer("localhost", port, "localhost", null, hostnameVerifier) == 200 && + sslSessionReference.get().getPeerCertificates()[0].toString().contains("CN=ActiveMQ Artemis Other Server,")); + + // check server certificate doesn't contain ActiveMQ Artemis Server + Assert.assertFalse(testSimpleSecureServer("localhost", port, "localhost", null, hostnameVerifier) == 200 && + sslSessionReference.get().getPeerCertificates()[0].toString().contains("CN=ActiveMQ Artemis Server,")); + } finally { + webServerComponent.stop(true); + } + } + + private int testSimpleSecureServer(String webServerHostname, int webServerPort, String requestHostname, String sniHostname) throws Exception { + return testSimpleSecureServer(webServerHostname, webServerPort, requestHostname, sniHostname, null); + } + + private int testSimpleSecureServer(String webServerHostname, int webServerPort, String requestHostname, String sniHostname, HostnameVerifier hostnameVerifier) throws Exception { HttpGet request = new HttpGet("https://" + (requestHostname != null ? requestHostname : webServerHostname) + ":" + webServerPort + "/WebServerComponentTest.txt"); HttpHost webServerHost = HttpHost.create("https://" + webServerHostname + ":" + webServerPort); SSLContext sslContext = new SSLContextBuilder().loadTrustMaterial(null, (certificate, authType) -> true).build(); - SSLConnectionSocketFactory sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContext, new NoopHostnameVerifier()) { + + if (hostnameVerifier == null) { + hostnameVerifier = new NoopHostnameVerifier(); + } + + SSLConnectionSocketFactory sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContext, hostnameVerifier) { @Override protected void prepareSocket(SSLSocket socket) throws IOException { super.prepareSocket(socket); diff --git a/docs/user-manual/web-server.adoc b/docs/user-manual/web-server.adoc index f25e1fdcaa..0a17a8d67b 100644 --- a/docs/user-manual/web-server.adoc +++ b/docs/user-manual/web-server.adoc @@ -43,6 +43,8 @@ The minimum number of threads the embedded web server will hold to service HTTP Default is `8` or the value of `maxThreads` if it is lower. idleThreadTimeout:: The time to wait before terminating an idle thread from the embedded web server. Measured in milliseconds. Default is `60000`. +scanPeriod:: +How often to scan for changes of the key and trust store files related to a binding when the `sslAutoReload` attribute value of the `binding` element is `true`, for further details see <>. Measured in seconds. Default is `5`. === Binding @@ -106,6 +108,11 @@ Whether or not the client request must include an SNI Host name. Default is `false`. Only applicable when using `https`. +sslAutoReload:: +Whether or not the key and trust store files must be watched for changes and automatically reloaded. +The watch period is controlled by the `scanPeriod` attribute of the `web` element, for further details see <>. +Default is `false`. + === App Each web application should be defined in an `app` element inside an `binding` element. diff --git a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/web/WebServerDTOConfigTest.java b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/web/WebServerDTOConfigTest.java index 58b7291256..864659334f 100644 --- a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/web/WebServerDTOConfigTest.java +++ b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/web/WebServerDTOConfigTest.java @@ -46,6 +46,7 @@ public class WebServerDTOConfigTest { properties.put(ActiveMQDefaultConfiguration.getDefaultSystemWebPropertyPrefix() + "customizer", "customizerTest"); properties.put(ActiveMQDefaultConfiguration.getDefaultSystemWebPropertyPrefix() + "rootRedirectLocation", "locationTest"); properties.put(ActiveMQDefaultConfiguration.getDefaultSystemWebPropertyPrefix() + "webContentEnabled", "true"); + properties.put(ActiveMQDefaultConfiguration.getDefaultSystemWebPropertyPrefix() + "scanPeriod", "1234"); properties.put(ActiveMQDefaultConfiguration.getDefaultSystemWebPropertyPrefix() + INVALID_ATTRIBUTE_NAME, "true"); Configuration configuration = new ConfigurationImpl(); String systemWebPropertyPrefix = ActiveMQDefaultConfiguration.getDefaultSystemWebPropertyPrefix(); @@ -54,6 +55,7 @@ public class WebServerDTOConfigTest { Assert.assertEquals("customizerTest", webServer.getCustomizer()); Assert.assertEquals("locationTest", webServer.getRootRedirectLocation()); Assert.assertEquals(true, webServer.getWebContentEnabled()); + Assert.assertEquals(Integer.valueOf(1234), webServer.getScanPeriod()); testStatus(configuration.getStatus(), "system-" + systemWebPropertyPrefix, ""); } @@ -102,6 +104,7 @@ public class WebServerDTOConfigTest { properties.put(ActiveMQDefaultConfiguration.getDefaultSystemWebPropertyPrefix() + "bindings." + bindingName + ".trustStorePassword", "test-trustStorePassword"); properties.put(ActiveMQDefaultConfiguration.getDefaultSystemWebPropertyPrefix() + "bindings." + bindingName + ".sniHostCheck", !WebServerComponent.DEFAULT_SNI_HOST_CHECK_VALUE); properties.put(ActiveMQDefaultConfiguration.getDefaultSystemWebPropertyPrefix() + "bindings." + bindingName + ".sniRequired", !WebServerComponent.DEFAULT_SNI_REQUIRED_VALUE); + properties.put(ActiveMQDefaultConfiguration.getDefaultSystemWebPropertyPrefix() + "bindings." + bindingName + ".sslAutoReload", !WebServerComponent.DEFAULT_SNI_REQUIRED_VALUE); properties.put(ActiveMQDefaultConfiguration.getDefaultSystemWebPropertyPrefix() + "bindings." + bindingName + "." + INVALID_ATTRIBUTE_NAME, "true"); Configuration configuration = new ConfigurationImpl(); String systemWebPropertyPrefix = ActiveMQDefaultConfiguration.getDefaultSystemWebPropertyPrefix(); @@ -123,6 +126,7 @@ public class WebServerDTOConfigTest { Assert.assertEquals("test-trustStorePassword", testBinding.getTrustStorePassword()); Assert.assertEquals(!WebServerComponent.DEFAULT_SNI_HOST_CHECK_VALUE, testBinding.getSniHostCheck()); Assert.assertEquals(!WebServerComponent.DEFAULT_SNI_REQUIRED_VALUE, testBinding.getSniRequired()); + Assert.assertEquals(!WebServerComponent.DEFAULT_SSL_AUTO_RELOAD_VALUE, testBinding.getSslAutoReload()); testStatus(configuration.getStatus(), "system-" + systemWebPropertyPrefix, "bindings." + bindingName + "."); }