ARTEMIS-4586 Auto reload web binding SSL stores on change

This commit is contained in:
Domenico Francesco Bruscino 2024-01-28 13:40:14 +01:00 committed by Robbie Gemmell
parent 20840cfdf1
commit 29781bd5da
8 changed files with 198 additions and 2 deletions

View File

@ -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<>();
}

View File

@ -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<BindingDTO> getBindings() {
return bindings;
}

View File

@ -49,6 +49,7 @@ public class WebServerDTOTest {
Assert.assertNull(defaultBinding.getTrustStorePassword());
Assert.assertNull(defaultBinding.getSniHostCheck());
Assert.assertNull(defaultBinding.getSniRequired());
Assert.assertNull(defaultBinding.getSslAutoReload());
}
@Test

View File

@ -189,6 +189,7 @@
<include>server-cert.pem</include>
<include>server-key.pem</include>
<include>server-pem-props-config.txt</include>
<include>other-server-keystore.p12</include>
</includes>
</resource>
</resources>

View File

@ -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<String, List<Runnable>> 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<Runnable> 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<Runnable> 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();

View File

@ -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<SSLSession> 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);

View File

@ -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 <<Binding>>. 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 <<Web>>.
Default is `false`.
=== App
Each web application should be defined in an `app` element inside an `binding` element.

View File

@ -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 + ".");
}