From 19fd88b25b651441f9bb09d1c846d79aff0e3552 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Mon, 16 Oct 2023 14:07:39 +0100 Subject: [PATCH] Implement SSL hot reload for Netty and Tomcat Closes gh-37808 --- .../boot/autoconfigure/ssl/FileWatcher.java | 229 ++++++++++++++++++ .../ssl/SslAutoConfiguration.java | 21 +- .../ssl/SslBundleProperties.java | 15 +- .../boot/autoconfigure/ssl/SslProperties.java | 41 ++++ .../ssl/SslPropertiesBundleRegistrar.java | 85 ++++++- .../autoconfigure/ssl/FileWatcherTests.java | 200 +++++++++++++++ .../SslPropertiesBundleRegistrarTests.java | 172 +++++++++++++ .../src/docs/asciidoc/features/ssl.adoc | 30 +++ .../netty/NettyRSocketServerFactory.java | 5 +- .../boot/ssl/DefaultSslBundleRegistry.java | 70 +++++- .../boot/ssl/SslBundleRegistry.java | 10 + .../springframework/boot/ssl/SslBundles.java | 16 +- .../netty/NettyReactiveWebServerFactory.java | 9 +- .../web/embedded/netty/NettyWebServer.java | 6 +- .../embedded/netty/SslServerCustomizer.java | 66 ++++- .../tomcat/SslConnectorCustomizer.java | 65 ++--- .../TomcatReactiveWebServerFactory.java | 12 +- .../tomcat/TomcatServletWebServerFactory.java | 12 +- .../AbstractConfigurableWebServerFactory.java | 9 + .../ssl/DefaultSslBundleRegistryTests.java | 48 +++- .../boot/ssl/pem/PemSslStoreBundleTests.java | 66 +++++ .../NettyReactiveWebServerFactoryTests.java | 38 ++- .../tomcat/SslConnectorCustomizerTests.java | 52 ++-- .../TomcatServletWebServerFactoryTests.java | 55 +++++ .../AbstractServletWebServerFactoryTests.java | 9 +- .../boot/web/embedded/netty/1.crt | 9 + .../boot/web/embedded/netty/1.key | 3 + .../boot/web/embedded/netty/2.crt | 9 + .../boot/web/embedded/netty/2.key | 3 + .../boot/web/embedded/tomcat/1.crt | 9 + .../boot/web/embedded/tomcat/1.key | 3 + .../boot/web/embedded/tomcat/2.crt | 9 + .../boot/web/embedded/tomcat/2.key | 3 + 33 files changed, 1285 insertions(+), 104 deletions(-) create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/FileWatcher.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/FileWatcherTests.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrarTests.java create mode 100644 spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/1.crt create mode 100644 spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/1.key create mode 100644 spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/2.crt create mode 100644 spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/2.key create mode 100644 spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/1.crt create mode 100644 spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/1.key create mode 100644 spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/2.crt create mode 100644 spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/2.key diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/FileWatcher.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/FileWatcher.java new file mode 100644 index 00000000000..eecad97b3b4 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/FileWatcher.java @@ -0,0 +1,229 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.autoconfigure.ssl; + +import java.io.Closeable; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.ClosedWatchServiceException; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.time.Duration; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.log.LogMessage; +import org.springframework.util.Assert; + +/** + * Watches files and directories and triggers a callback on change. + * + * @author Moritz Halbritter + * @author Phillip Webb + */ +class FileWatcher implements Closeable { + + private static final Log logger = LogFactory.getLog(FileWatcher.class); + + private final Duration quietPeriod; + + private final Object lock = new Object(); + + private WatcherThread thread; + + /** + * Create a new {@link FileWatcher} instance. + * @param quietPeriod the duration that no file changes should occur before triggering + * actions + */ + FileWatcher(Duration quietPeriod) { + Assert.notNull(quietPeriod, "QuietPeriod must not be null"); + this.quietPeriod = quietPeriod; + } + + /** + * Watch the given files or directories for changes. + * @param paths the files or directories to watch + * @param action the action to take when changes are detected + */ + void watch(Set paths, Runnable action) { + Assert.notNull(paths, "Paths must not be null"); + Assert.notNull(action, "Action must not be null"); + if (paths.isEmpty()) { + return; + } + synchronized (this.lock) { + try { + if (this.thread == null) { + this.thread = new WatcherThread(); + this.thread.start(); + } + this.thread.register(new Registration(paths, action)); + } + catch (IOException ex) { + throw new UncheckedIOException("Failed to register paths for watching: " + paths, ex); + } + } + } + + @Override + public void close() throws IOException { + synchronized (this.lock) { + if (this.thread != null) { + this.thread.close(); + this.thread.interrupt(); + try { + this.thread.join(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + this.thread = null; + } + } + } + + /** + * The watcher thread used to check for changes. + */ + private class WatcherThread extends Thread implements Closeable { + + private final WatchService watchService = FileSystems.getDefault().newWatchService(); + + private final Map> registrations = new ConcurrentHashMap<>(); + + private volatile boolean running = true; + + WatcherThread() throws IOException { + setName("ssl-bundle-watcher"); + setDaemon(true); + setUncaughtExceptionHandler(this::onThreadException); + } + + private void onThreadException(Thread thread, Throwable throwable) { + logger.error("Uncaught exception in file watcher thread", throwable); + } + + void register(Registration registration) throws IOException { + for (Path path : registration.paths()) { + if (!Files.isRegularFile(path) && !Files.isDirectory(path)) { + throw new IOException("'%s' is neither a file nor a directory".formatted(path)); + } + Path directory = Files.isDirectory(path) ? path : path.getParent(); + WatchKey watchKey = register(directory); + this.registrations.computeIfAbsent(watchKey, (key) -> new CopyOnWriteArrayList<>()).add(registration); + } + } + + private WatchKey register(Path directory) throws IOException { + logger.debug(LogMessage.format("Registering '%s'", directory)); + return directory.register(this.watchService, StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); + } + + @Override + public void run() { + logger.debug("Watch thread started"); + Set actions = new HashSet<>(); + while (this.running) { + try { + long timeout = FileWatcher.this.quietPeriod.toMillis(); + WatchKey key = this.watchService.poll(timeout, TimeUnit.MILLISECONDS); + if (key == null) { + actions.forEach(this::runSafely); + actions.clear(); + } + else { + accumulate(key, actions); + key.reset(); + } + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + catch (ClosedWatchServiceException ex) { + logger.debug("File watcher has been closed"); + this.running = false; + } + } + logger.debug("Watch thread stopped"); + } + + private void runSafely(Runnable action) { + try { + action.run(); + } + catch (Throwable ex) { + logger.error("Unexpected SSL reload error", ex); + } + } + + private void accumulate(WatchKey key, Set actions) { + List registrations = this.registrations.get(key); + Path directory = (Path) key.watchable(); + for (WatchEvent event : key.pollEvents()) { + Path file = directory.resolve((Path) event.context()); + for (Registration registration : registrations) { + if (registration.manages(file)) { + actions.add(registration.action()); + } + } + } + } + + @Override + public void close() throws IOException { + this.running = false; + this.watchService.close(); + } + + } + + /** + * An individual watch registration. + */ + private record Registration(Set paths, Runnable action) { + + Registration { + paths = paths.stream().map(Path::toAbsolutePath).collect(Collectors.toSet()); + } + + boolean manages(Path file) { + Path absolutePath = file.toAbsolutePath(); + return this.paths.contains(absolutePath) || isInDirectories(absolutePath); + } + + private boolean isInDirectories(Path file) { + return this.paths.stream().filter(Files::isDirectory).anyMatch(file::startsWith); + } + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslAutoConfiguration.java index 12b856c8a01..1348f16b37b 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslAutoConfiguration.java @@ -16,8 +16,7 @@ package org.springframework.boot.autoconfigure.ssl; -import java.util.List; - +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -37,19 +36,27 @@ import org.springframework.context.annotation.Bean; @EnableConfigurationProperties(SslProperties.class) public class SslAutoConfiguration { - SslAutoConfiguration() { + private final SslProperties sslProperties; + + SslAutoConfiguration(SslProperties sslProperties) { + this.sslProperties = sslProperties; } @Bean - public SslPropertiesBundleRegistrar sslPropertiesSslBundleRegistrar(SslProperties sslProperties) { - return new SslPropertiesBundleRegistrar(sslProperties); + FileWatcher fileWatcher() { + return new FileWatcher(this.sslProperties.getBundle().getWatch().getFile().getQuietPeriod()); + } + + @Bean + SslPropertiesBundleRegistrar sslPropertiesSslBundleRegistrar(FileWatcher fileWatcher) { + return new SslPropertiesBundleRegistrar(this.sslProperties, fileWatcher); } @Bean @ConditionalOnMissingBean({ SslBundleRegistry.class, SslBundles.class }) - public DefaultSslBundleRegistry sslBundleRegistry(List sslBundleRegistrars) { + DefaultSslBundleRegistry sslBundleRegistry(ObjectProvider sslBundleRegistrars) { DefaultSslBundleRegistry registry = new DefaultSslBundleRegistry(); - sslBundleRegistrars.forEach((registrar) -> registrar.registerBundles(registry)); + sslBundleRegistrars.orderedStream().forEach((registrar) -> registrar.registerBundles(registry)); return registry; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslBundleProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslBundleProperties.java index e8b9fd1a4cb..b01201dba07 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslBundleProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslBundleProperties.java @@ -36,7 +36,7 @@ public abstract class SslBundleProperties { private final Key key = new Key(); /** - * Options for the SLL connection. + * Options for the SSL connection. */ private final Options options = new Options(); @@ -45,6 +45,11 @@ public abstract class SslBundleProperties { */ private String protocol = SslBundle.DEFAULT_PROTOCOL; + /** + * Whether to reload the SSL bundle. + */ + private boolean reloadOnUpdate; + public Key getKey() { return this.key; } @@ -61,6 +66,14 @@ public abstract class SslBundleProperties { this.protocol = protocol; } + public boolean isReloadOnUpdate() { + return this.reloadOnUpdate; + } + + public void setReloadOnUpdate(boolean reloadOnUpdate) { + this.reloadOnUpdate = reloadOnUpdate; + } + public static class Options { /** diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslProperties.java index 49aced74902..a755a871b8d 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslProperties.java @@ -16,6 +16,7 @@ package org.springframework.boot.autoconfigure.ssl; +import java.time.Duration; import java.util.LinkedHashMap; import java.util.Map; @@ -25,6 +26,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; * Properties for centralized SSL trust material configuration. * * @author Scott Frederick + * @author Moritz Halbritter * @since 3.1.0 */ @ConfigurationProperties(prefix = "spring.ssl") @@ -54,6 +56,11 @@ public class SslProperties { */ private final Map jks = new LinkedHashMap<>(); + /** + * Trust material watching. + */ + private final Watch watch = new Watch(); + public Map getPem() { return this.pem; } @@ -62,6 +69,40 @@ public class SslProperties { return this.jks; } + public Watch getWatch() { + return this.watch; + } + + public static class Watch { + + /** + * File watching. + */ + private final File file = new File(); + + public File getFile() { + return this.file; + } + + public static class File { + + /** + * Quiet period, after which changes are detected. + */ + private Duration quietPeriod = Duration.ofSeconds(10); + + public Duration getQuietPeriod() { + return this.quietPeriod; + } + + public void setQuietPeriod(Duration quietPeriod) { + this.quietPeriod = quietPeriod; + } + + } + + } + } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrar.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrar.java index 89a3e7c1265..531fb8d574f 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrar.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrar.java @@ -16,11 +16,22 @@ package org.springframework.boot.autoconfigure.ssl; +import java.io.FileNotFoundException; +import java.io.UncheckedIOException; +import java.net.URL; +import java.nio.file.Path; +import java.util.LinkedHashSet; import java.util.Map; +import java.util.Set; import java.util.function.Function; +import java.util.regex.Pattern; +import java.util.stream.Collectors; import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.ssl.SslBundleRegistry; +import org.springframework.util.Assert; +import org.springframework.util.ResourceUtils; +import org.springframework.util.StringUtils; /** * A {@link SslBundleRegistrar} that registers SSL bundles based @@ -28,25 +39,87 @@ import org.springframework.boot.ssl.SslBundleRegistry; * * @author Scott Frederick * @author Phillip Webb + * @author Moritz Halbritter */ class SslPropertiesBundleRegistrar implements SslBundleRegistrar { + private static final Pattern PEM_CONTENT = Pattern.compile("-+BEGIN\\s+[^-]*-+", Pattern.CASE_INSENSITIVE); + private final SslProperties.Bundles properties; - SslPropertiesBundleRegistrar(SslProperties properties) { + private final FileWatcher fileWatcher; + + SslPropertiesBundleRegistrar(SslProperties properties, FileWatcher fileWatcher) { this.properties = properties.getBundle(); + this.fileWatcher = fileWatcher; } @Override public void registerBundles(SslBundleRegistry registry) { - registerBundles(registry, this.properties.getPem(), PropertiesSslBundle::get); - registerBundles(registry, this.properties.getJks(), PropertiesSslBundle::get); + registerBundles(registry, this.properties.getPem(), PropertiesSslBundle::get, this::getLocations); + registerBundles(registry, this.properties.getJks(), PropertiesSslBundle::get, this::getLocations); } private

void registerBundles(SslBundleRegistry registry, Map properties, - Function bundleFactory) { - properties.forEach((bundleName, bundleProperties) -> registry.registerBundle(bundleName, - bundleFactory.apply(bundleProperties))); + Function bundleFactory, Function> locationsSupplier) { + properties.forEach((bundleName, bundleProperties) -> { + SslBundle bundle = bundleFactory.apply(bundleProperties); + registry.registerBundle(bundleName, bundle); + if (bundleProperties.isReloadOnUpdate()) { + Set paths = locationsSupplier.apply(bundleProperties) + .stream() + .filter(Location::hasValue) + .map((location) -> toPath(bundleName, location)) + .collect(Collectors.toSet()); + this.fileWatcher.watch(paths, + () -> registry.updateBundle(bundleName, bundleFactory.apply(bundleProperties))); + } + }); + } + + private Set getLocations(JksSslBundleProperties properties) { + JksSslBundleProperties.Store keystore = properties.getKeystore(); + JksSslBundleProperties.Store truststore = properties.getTruststore(); + Set locations = new LinkedHashSet<>(); + locations.add(new Location("keystore.location", keystore.getLocation())); + locations.add(new Location("truststore.location", truststore.getLocation())); + return locations; + } + + private Set getLocations(PemSslBundleProperties properties) { + PemSslBundleProperties.Store keystore = properties.getKeystore(); + PemSslBundleProperties.Store truststore = properties.getTruststore(); + Set locations = new LinkedHashSet<>(); + locations.add(new Location("keystore.private-key", keystore.getPrivateKey())); + locations.add(new Location("keystore.certificate", keystore.getCertificate())); + locations.add(new Location("truststore.private-key", truststore.getPrivateKey())); + locations.add(new Location("truststore.certificate", truststore.getCertificate())); + return locations; + } + + private Path toPath(String bundleName, Location watchableLocation) { + String value = watchableLocation.value(); + String field = watchableLocation.field(); + Assert.state(!PEM_CONTENT.matcher(value).find(), + () -> "SSL bundle '%s' '%s' is not a URL and can't be watched".formatted(bundleName, field)); + try { + URL url = ResourceUtils.getURL(value); + Assert.state("file".equalsIgnoreCase(url.getProtocol()), + () -> "SSL bundle '%s' '%s' URL '%s' doesn't point to a file".formatted(bundleName, field, url)); + return Path.of(url.getFile()).toAbsolutePath(); + } + catch (FileNotFoundException ex) { + throw new UncheckedIOException( + "SSL bundle '%s' '%s' location '%s' cannot be watched".formatted(bundleName, field, value), ex); + } + } + + private record Location(String field, String value) { + + boolean hasValue() { + return StringUtils.hasText(this.value); + } + } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/FileWatcherTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/FileWatcherTests.java new file mode 100644 index 00000000000..e680479d0e4 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/FileWatcherTests.java @@ -0,0 +1,200 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.autoconfigure.ssl; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.fail; + +/** + * Tests for {@link FileWatcher}. + * + * @author Moritz Halbritter + */ +class FileWatcherTests { + + private FileWatcher fileWatcher; + + @BeforeEach + void setUp() { + this.fileWatcher = new FileWatcher(Duration.ofMillis(10)); + } + + @AfterEach + void tearDown() throws IOException { + this.fileWatcher.close(); + } + + @Test + void shouldTriggerOnFileCreation(@TempDir Path tempDir) throws Exception { + Path newFile = tempDir.resolve("new-file.txt"); + WaitingCallback callback = new WaitingCallback(); + this.fileWatcher.watch(Set.of(tempDir), callback); + Files.createFile(newFile); + callback.expectChanges(); + } + + @Test + void shouldTriggerOnFileDeletion(@TempDir Path tempDir) throws Exception { + Path deletedFile = tempDir.resolve("deleted-file.txt"); + Files.createFile(deletedFile); + WaitingCallback callback = new WaitingCallback(); + this.fileWatcher.watch(Set.of(tempDir), callback); + Files.delete(deletedFile); + callback.expectChanges(); + } + + @Test + void shouldTriggerOnFileModification(@TempDir Path tempDir) throws Exception { + Path deletedFile = tempDir.resolve("modified-file.txt"); + Files.createFile(deletedFile); + WaitingCallback callback = new WaitingCallback(); + this.fileWatcher.watch(Set.of(tempDir), callback); + Files.writeString(deletedFile, "Some content"); + callback.expectChanges(); + } + + @Test + void shouldWatchFile(@TempDir Path tempDir) throws Exception { + Path watchedFile = tempDir.resolve("watched.txt"); + Files.createFile(watchedFile); + WaitingCallback callback = new WaitingCallback(); + this.fileWatcher.watch(Set.of(watchedFile), callback); + Files.writeString(watchedFile, "Some content"); + callback.expectChanges(); + } + + @Test + void shouldIgnoreNotWatchedFiles(@TempDir Path tempDir) throws Exception { + Path watchedFile = tempDir.resolve("watched.txt"); + Path notWatchedFile = tempDir.resolve("not-watched.txt"); + Files.createFile(watchedFile); + Files.createFile(notWatchedFile); + WaitingCallback callback = new WaitingCallback(); + this.fileWatcher.watch(Set.of(watchedFile), callback); + Files.writeString(notWatchedFile, "Some content"); + callback.expectNoChanges(); + } + + @Test + void shouldFailIfDirectoryOrFileDoesNotExist(@TempDir Path tempDir) { + Path directory = tempDir.resolve("dir1"); + assertThatExceptionOfType(UncheckedIOException.class) + .isThrownBy(() -> this.fileWatcher.watch(Set.of(directory), new WaitingCallback())) + .withMessageMatching("Failed to register paths for watching: \\[.+/dir1]"); + } + + @Test + void shouldNotFailIfDirectoryIsRegisteredMultipleTimes(@TempDir Path tempDir) { + WaitingCallback callback = new WaitingCallback(); + assertThatCode(() -> { + this.fileWatcher.watch(Set.of(tempDir), callback); + this.fileWatcher.watch(Set.of(tempDir), callback); + }).doesNotThrowAnyException(); + } + + @Test + void shouldNotFailIfStoppedMultipleTimes(@TempDir Path tempDir) { + WaitingCallback callback = new WaitingCallback(); + this.fileWatcher.watch(Set.of(tempDir), callback); + assertThatCode(() -> { + this.fileWatcher.close(); + this.fileWatcher.close(); + }).doesNotThrowAnyException(); + } + + @Test + void testRelativeFiles() throws Exception { + Path watchedFile = Path.of(UUID.randomUUID() + ".txt"); + Files.createFile(watchedFile); + try { + WaitingCallback callback = new WaitingCallback(); + this.fileWatcher.watch(Set.of(watchedFile), callback); + Files.delete(watchedFile); + callback.expectChanges(); + } + finally { + Files.deleteIfExists(watchedFile); + } + } + + @Test + void testRelativeDirectories() throws Exception { + Path watchedDirectory = Path.of(UUID.randomUUID() + "/"); + Path file = watchedDirectory.resolve("file.txt"); + Files.createDirectory(watchedDirectory); + try { + WaitingCallback callback = new WaitingCallback(); + this.fileWatcher.watch(Set.of(watchedDirectory), callback); + Files.createFile(file); + callback.expectChanges(); + } + finally { + Files.deleteIfExists(file); + Files.deleteIfExists(watchedDirectory); + } + } + + private static class WaitingCallback implements Runnable { + + private final CountDownLatch latch = new CountDownLatch(1); + + volatile boolean changed = false; + + @Override + public void run() { + this.changed = true; + this.latch.countDown(); + } + + void expectChanges() throws InterruptedException { + waitForChanges(true); + assertThat(this.changed).as("changed").isTrue(); + } + + void expectNoChanges() throws InterruptedException { + waitForChanges(false); + assertThat(this.changed).as("changed").isFalse(); + } + + void waitForChanges(boolean fail) throws InterruptedException { + if (!this.latch.await(5, TimeUnit.SECONDS)) { + if (fail) { + fail("Timeout while waiting for changes"); + } + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrarTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrarTests.java new file mode 100644 index 00000000000..2410b8bbaae --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrarTests.java @@ -0,0 +1,172 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.autoconfigure.ssl; + +import java.nio.file.Path; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.boot.ssl.SslBundleRegistry; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.assertArg; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +/** + * Tests for {@link SslPropertiesBundleRegistrar}. + * + * @author Moritz Halbritter + */ +class SslPropertiesBundleRegistrarTests { + + private SslPropertiesBundleRegistrar registrar; + + private FileWatcher fileWatcher; + + private SslProperties properties; + + private SslBundleRegistry registry; + + @BeforeEach + void setUp() { + this.properties = new SslProperties(); + this.fileWatcher = Mockito.mock(FileWatcher.class); + this.registrar = new SslPropertiesBundleRegistrar(this.properties, this.fileWatcher); + this.registry = Mockito.mock(SslBundleRegistry.class); + } + + @Test + void shouldWatchJksBundles() { + JksSslBundleProperties jks = new JksSslBundleProperties(); + jks.setReloadOnUpdate(true); + jks.getKeystore().setLocation("classpath:test.jks"); + jks.getKeystore().setPassword("secret"); + jks.getTruststore().setLocation("classpath:test.jks"); + jks.getTruststore().setPassword("secret"); + this.properties.getBundle().getJks().put("bundle1", jks); + this.registrar.registerBundles(this.registry); + then(this.registry).should(times(1)).registerBundle(eq("bundle1"), any()); + then(this.fileWatcher).should().watch(assertArg((set) -> pathEndingWith(set, "test.jks")), any()); + } + + @Test + void shouldWatchPemBundles() { + PemSslBundleProperties pem = new PemSslBundleProperties(); + pem.setReloadOnUpdate(true); + pem.getKeystore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/rsa-cert.pem"); + pem.getKeystore().setPrivateKey("classpath:org/springframework/boot/autoconfigure/ssl/rsa-key.pem"); + pem.getTruststore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem"); + pem.getTruststore().setPrivateKey("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-key.pem"); + this.properties.getBundle().getPem().put("bundle1", pem); + this.registrar.registerBundles(this.registry); + then(this.registry).should(times(1)).registerBundle(eq("bundle1"), any()); + then(this.fileWatcher).should() + .watch(assertArg((set) -> pathEndingWith(set, "rsa-cert.pem", "rsa-key.pem")), any()); + } + + @Test + void shouldFailIfPemKeystoreCertificateIsEmbedded() { + PemSslBundleProperties pem = new PemSslBundleProperties(); + pem.setReloadOnUpdate(true); + pem.getKeystore().setCertificate(""" + -----BEGIN CERTIFICATE----- + MIICCzCCAb2gAwIBAgIUZbDi7G5czH+Yi0k2EMWxdf00XagwBQYDK2VwMHsxCzAJ + BgNVBAYTAlhYMRIwEAYDVQQIDAlTdGF0ZU5hbWUxETAPBgNVBAcMCENpdHlOYW1l + MRQwEgYDVQQKDAtDb21wYW55TmFtZTEbMBkGA1UECwwSQ29tcGFueVNlY3Rpb25O + YW1lMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjMwOTExMTIxNDMwWhcNMzMwOTA4 + MTIxNDMwWjB7MQswCQYDVQQGEwJYWDESMBAGA1UECAwJU3RhdGVOYW1lMREwDwYD + VQQHDAhDaXR5TmFtZTEUMBIGA1UECgwLQ29tcGFueU5hbWUxGzAZBgNVBAsMEkNv + bXBhbnlTZWN0aW9uTmFtZTESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEA + Q/DDA4BSgZ+Hx0DUxtIRjVjN+OcxXVURwAWc3Gt9GUyjUzBRMB0GA1UdDgQWBBSv + EdpoaBMBoxgO96GFbf03k07DSTAfBgNVHSMEGDAWgBSvEdpoaBMBoxgO96GFbf03 + k07DSTAPBgNVHRMBAf8EBTADAQH/MAUGAytlcANBAHMXDkGd57d4F4cRk/8UjhxD + 7OtRBZfdfznSvlhJIMNfH5q0zbC2eO3hWCB3Hrn/vIeswGP8Ov4AJ6eXeX44BQM= + -----END CERTIFICATE----- + """.strip()); + this.properties.getBundle().getPem().put("bundle1", pem); + assertThatIllegalStateException().isThrownBy(() -> this.registrar.registerBundles(this.registry)) + .withMessage("SSL bundle 'bundle1' 'keystore.certificate' is not a URL and can't be watched"); + } + + @Test + void shouldFailIfPemKeystorePrivateKeyIsEmbedded() { + PemSslBundleProperties pem = new PemSslBundleProperties(); + pem.setReloadOnUpdate(true); + pem.getKeystore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem"); + pem.getKeystore().setPrivateKey(""" + -----BEGIN PRIVATE KEY----- + MC4CAQAwBQYDK2VwBCIEIC29RnMVTcyqXEAIO1b/6p7RdbM6TiqvnztVQ4IxYxUh + -----END PRIVATE KEY----- + """.strip()); + this.properties.getBundle().getPem().put("bundle1", pem); + assertThatIllegalStateException().isThrownBy(() -> this.registrar.registerBundles(this.registry)) + .withMessage("SSL bundle 'bundle1' 'keystore.private-key' is not a URL and can't be watched"); + } + + @Test + void shouldFailIfPemTruststoreCertificateIsEmbedded() { + PemSslBundleProperties pem = new PemSslBundleProperties(); + pem.setReloadOnUpdate(true); + pem.getTruststore().setCertificate(""" + -----BEGIN CERTIFICATE----- + MIICCzCCAb2gAwIBAgIUZbDi7G5czH+Yi0k2EMWxdf00XagwBQYDK2VwMHsxCzAJ + BgNVBAYTAlhYMRIwEAYDVQQIDAlTdGF0ZU5hbWUxETAPBgNVBAcMCENpdHlOYW1l + MRQwEgYDVQQKDAtDb21wYW55TmFtZTEbMBkGA1UECwwSQ29tcGFueVNlY3Rpb25O + YW1lMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjMwOTExMTIxNDMwWhcNMzMwOTA4 + MTIxNDMwWjB7MQswCQYDVQQGEwJYWDESMBAGA1UECAwJU3RhdGVOYW1lMREwDwYD + VQQHDAhDaXR5TmFtZTEUMBIGA1UECgwLQ29tcGFueU5hbWUxGzAZBgNVBAsMEkNv + bXBhbnlTZWN0aW9uTmFtZTESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEA + Q/DDA4BSgZ+Hx0DUxtIRjVjN+OcxXVURwAWc3Gt9GUyjUzBRMB0GA1UdDgQWBBSv + EdpoaBMBoxgO96GFbf03k07DSTAfBgNVHSMEGDAWgBSvEdpoaBMBoxgO96GFbf03 + k07DSTAPBgNVHRMBAf8EBTADAQH/MAUGAytlcANBAHMXDkGd57d4F4cRk/8UjhxD + 7OtRBZfdfznSvlhJIMNfH5q0zbC2eO3hWCB3Hrn/vIeswGP8Ov4AJ6eXeX44BQM= + -----END CERTIFICATE----- + """.strip()); + this.properties.getBundle().getPem().put("bundle1", pem); + assertThatIllegalStateException().isThrownBy(() -> this.registrar.registerBundles(this.registry)) + .withMessage("SSL bundle 'bundle1' 'truststore.certificate' is not a URL and can't be watched"); + } + + @Test + void shouldFailIfPemTruststorePrivateKeyIsEmbedded() { + PemSslBundleProperties pem = new PemSslBundleProperties(); + pem.setReloadOnUpdate(true); + pem.getTruststore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem"); + pem.getTruststore().setPrivateKey(""" + -----BEGIN PRIVATE KEY----- + MC4CAQAwBQYDK2VwBCIEIC29RnMVTcyqXEAIO1b/6p7RdbM6TiqvnztVQ4IxYxUh + -----END PRIVATE KEY----- + """.strip()); + this.properties.getBundle().getPem().put("bundle1", pem); + assertThatIllegalStateException().isThrownBy(() -> this.registrar.registerBundles(this.registry)) + .withMessage("SSL bundle 'bundle1' 'truststore.private-key' is not a URL and can't be watched"); + } + + private void pathEndingWith(Set paths, String... suffixes) { + for (String suffix : suffixes) { + assertThat(paths).anyMatch((path) -> path.getFileName().toString().endsWith(suffix)); + } + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/ssl.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/ssl.adoc index 759755c25aa..23f7ee3635e 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/ssl.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/ssl.adoc @@ -104,3 +104,33 @@ In addition, the `SslBundle` provides details about the key being used, the prot The following example shows retrieving an `SslBundle` and using it to create an `SSLContext`: include::code:MyComponent[] + +[[features.ssl.reloading]] +=== Reloading SSL bundles + +SSL bundles can be reloaded when the key material changes. +The component consuming the bundle has to be compatible with reloadable SSL bundles. +Currently the following components are compatible: + +* Tomcat web server +* Netty web server + +To enable reloading, you need to opt-in via a configuration property as shown in this example: + +[source,yaml,indent=0,subs="verbatim",configblocks] +---- + spring: + ssl: + bundle: + pem: + mybundle: + reload-on-update: true + keystore: + certificate: "file:/some/directory/application.crt" + private-key: "file:/some/directory/application.key" +---- + +A file watcher is then watching the files and if they change, the SSL bundle will be reloaded. +This in turn triggers a reload in the consuming component, e.g. Tomcat rotates the certificates in the SSL enabled connectors. + +You can configure the quiet period (to make sure that there are no more changes) of the file watcher with the configprop:spring.ssl.bundle.watch.file.quiet-period[] property. diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactory.java index 7547b8d68e6..e291716cac7 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactory.java @@ -219,12 +219,15 @@ public class NettyRSocketServerFactory implements RSocketServerFactory, Configur private static final class TcpSslServerCustomizer extends org.springframework.boot.web.embedded.netty.SslServerCustomizer { + private final SslBundle sslBundle; + private TcpSslServerCustomizer(Ssl.ClientAuth clientAuth, SslBundle sslBundle) { super(null, clientAuth, sslBundle); + this.sslBundle = sslBundle; } private TcpServer apply(TcpServer server) { - AbstractProtocolSslContextSpec sslContextSpec = createSslContextSpec(); + AbstractProtocolSslContextSpec sslContextSpec = createSslContextSpec(this.sslBundle); return server.secure((spec) -> spec.sslContext(sslContextSpec)); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslBundleRegistry.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslBundleRegistry.java index fa79265755c..8c999e5ccf4 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslBundleRegistry.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslBundleRegistry.java @@ -16,20 +16,31 @@ package org.springframework.boot.ssl; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.log.LogMessage; import org.springframework.util.Assert; /** * Default {@link SslBundleRegistry} implementation. * * @author Scott Frederick + * @author Moritz Halbritter + * @author Phillip Webb * @since 3.1.0 */ public class DefaultSslBundleRegistry implements SslBundleRegistry, SslBundles { - private final Map bundles = new ConcurrentHashMap<>(); + private static final Log logger = LogFactory.getLog(DefaultSslBundleRegistry.class); + + private final Map registeredBundles = new ConcurrentHashMap<>(); public DefaultSslBundleRegistry() { } @@ -42,18 +53,67 @@ public class DefaultSslBundleRegistry implements SslBundleRegistry, SslBundles { public void registerBundle(String name, SslBundle bundle) { Assert.notNull(name, "Name must not be null"); Assert.notNull(bundle, "Bundle must not be null"); - SslBundle previous = this.bundles.putIfAbsent(name, bundle); + RegisteredSslBundle previous = this.registeredBundles.putIfAbsent(name, new RegisteredSslBundle(name, bundle)); Assert.state(previous == null, () -> "Cannot replace existing SSL bundle '%s'".formatted(name)); } + @Override + public void updateBundle(String name, SslBundle updatedBundle) { + getRegistered(name).update(updatedBundle); + } + @Override public SslBundle getBundle(String name) { + return getRegistered(name).getBundle(); + } + + @Override + public void addBundleUpdateHandler(String name, Consumer updateHandler) throws NoSuchSslBundleException { + getRegistered(name).addUpdateHandler(updateHandler); + } + + private RegisteredSslBundle getRegistered(String name) throws NoSuchSslBundleException { Assert.notNull(name, "Name must not be null"); - SslBundle bundle = this.bundles.get(name); - if (bundle == null) { + RegisteredSslBundle registered = this.registeredBundles.get(name); + if (registered == null) { throw new NoSuchSslBundleException(name, "SSL bundle name '%s' cannot be found".formatted(name)); } - return bundle; + return registered; + } + + private static class RegisteredSslBundle { + + private final String name; + + private final List> updateHandlers = new CopyOnWriteArrayList<>(); + + private volatile SslBundle bundle; + + RegisteredSslBundle(String name, SslBundle bundle) { + this.name = name; + this.bundle = bundle; + } + + void update(SslBundle updatedBundle) { + Assert.notNull(updatedBundle, "UpdatedBundle must not be null"); + this.bundle = updatedBundle; + if (this.updateHandlers.isEmpty()) { + logger.warn(LogMessage.format( + "SSL bundle '%s' has been updated but may be in use by a technology that doesn't support SSL reloading", + this.name)); + } + this.updateHandlers.forEach((handler) -> handler.accept(updatedBundle)); + } + + SslBundle getBundle() { + return this.bundle; + } + + void addUpdateHandler(Consumer updateHandler) { + Assert.notNull(updateHandler, "UpdateHandler must not be null"); + this.updateHandlers.add(updateHandler); + } + } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleRegistry.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleRegistry.java index 990a481066b..e1c0a4c6417 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleRegistry.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleRegistry.java @@ -20,6 +20,7 @@ package org.springframework.boot.ssl; * Interface that can be used to register an {@link SslBundle} for a given name. * * @author Scott Frederick + * @author Moritz Halbritter * @since 3.1.0 */ public interface SslBundleRegistry { @@ -31,4 +32,13 @@ public interface SslBundleRegistry { */ void registerBundle(String name, SslBundle bundle); + /** + * Updates an {@link SslBundle}. + * @param name the bundle name + * @param updatedBundle the updated bundle + * @throws NoSuchSslBundleException if the bundle cannot be found + * @since 3.2.0 + */ + void updateBundle(String name, SslBundle updatedBundle) throws NoSuchSslBundleException; + } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundles.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundles.java index ed8a0ea9cda..21afc4346a6 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundles.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundles.java @@ -16,20 +16,32 @@ package org.springframework.boot.ssl; +import java.util.function.Consumer; + /** * A managed set of {@link SslBundle} instances that can be retrieved by name. * * @author Scott Frederick + * @author Moritz Halbritter * @since 3.1.0 */ public interface SslBundles { /** * Return an {@link SslBundle} with the provided name. - * @param bundleName the bundle name + * @param name the bundle name * @return the bundle * @throws NoSuchSslBundleException if a bundle with the provided name does not exist */ - SslBundle getBundle(String bundleName) throws NoSuchSslBundleException; + SslBundle getBundle(String name) throws NoSuchSslBundleException; + + /** + * Add a handler that will be called each time the named bundle is updated. + * @param name the bundle name + * @param updateHandler the handler that should be called + * @throws NoSuchSslBundleException if a bundle with the provided name does not exist + * @since 3.2.0 + */ + void addBundleUpdateHandler(String name, Consumer updateHandler) throws NoSuchSslBundleException; } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactory.java index 8f7fa831a8c..ee8c014ec3d 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactory.java @@ -37,11 +37,13 @@ import org.springframework.http.client.ReactorResourceFactory; import org.springframework.http.server.reactive.HttpHandler; import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; /** * {@link ReactiveWebServerFactory} that can be used to create {@link NettyWebServer}s. * * @author Brian Clozel + * @author Moritz Halbritter * @since 2.0.0 */ public class NettyReactiveWebServerFactory extends AbstractReactiveWebServerFactory { @@ -170,7 +172,12 @@ public class NettyReactiveWebServerFactory extends AbstractReactiveWebServerFact } private HttpServer customizeSslConfiguration(HttpServer httpServer) { - return new SslServerCustomizer(getHttp2(), getSsl().getClientAuth(), getSslBundle()).apply(httpServer); + SslServerCustomizer customizer = new SslServerCustomizer(getHttp2(), getSsl().getClientAuth(), getSslBundle()); + String bundleName = getSsl().getBundle(); + if (StringUtils.hasText(bundleName)) { + getSslBundles().addBundleUpdateHandler(bundleName, customizer::updateSslBundle); + } + return customizer.apply(httpServer); } private HttpProtocol[] listProtocols() { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyWebServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyWebServer.java index 543ffdbd9fa..a9e6e6c2c95 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyWebServer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyWebServer.java @@ -106,7 +106,6 @@ public class NettyWebServer implements WebServer { * @param resourceFactory the factory for the server's {@link LoopResources loop * resources}, may be {@code null} * @since 3.2.0 - * {@link #NettyWebServer(HttpServer, ReactorHttpHandlerAdapter, Duration, Shutdown, ReactorResourceFactory)} */ public NettyWebServer(HttpServer httpServer, ReactorHttpHandlerAdapter handlerAdapter, Duration lifecycleTimeout, Shutdown shutdown, ReactorResourceFactory resourceFactory) { @@ -149,7 +148,7 @@ public class NettyWebServer implements WebServer { StringBuilder message = new StringBuilder(); tryAppend(message, "port %s", server::port); tryAppend(message, "path %s", server::path); - return (message.length() > 0) ? "Netty started on " + message : "Netty started"; + return (!message.isEmpty()) ? "Netty started on " + message : "Netty started"; } protected String getStartedLogMessage() { @@ -159,10 +158,11 @@ public class NettyWebServer implements WebServer { private void tryAppend(StringBuilder message, String format, Supplier supplier) { try { Object value = supplier.get(); - message.append((message.length() != 0) ? " " : ""); + message.append((!message.isEmpty()) ? " " : ""); message.append(String.format(format, value)); } catch (UnsupportedOperationException ex) { + // Ignore } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/SslServerCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/SslServerCustomizer.java index 5480c4d0c87..204868e0608 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/SslServerCustomizer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/SslServerCustomizer.java @@ -17,10 +17,14 @@ package org.springframework.boot.web.embedded.netty; import io.netty.handler.ssl.ClientAuth; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import reactor.netty.http.Http11SslContextSpec; import reactor.netty.http.Http2SslContextSpec; import reactor.netty.http.server.HttpServer; import reactor.netty.tcp.AbstractProtocolSslContextSpec; +import reactor.netty.tcp.SslProvider; +import reactor.netty.tcp.SslProvider.SslContextSpec; import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.ssl.SslOptions; @@ -36,41 +40,77 @@ import org.springframework.boot.web.server.Ssl; * @author Chris Bono * @author Cyril Dangerville * @author Scott Frederick + * @author Moritz Halbritter + * @author Phillip Webb * @since 2.0.0 */ public class SslServerCustomizer implements NettyServerCustomizer { + private static final Log logger = LogFactory.getLog(SslServerCustomizer.class); + private final Http2 http2; - private final Ssl.ClientAuth clientAuth; + private final ClientAuth clientAuth; - private final SslBundle sslBundle; + private volatile SslProvider sslProvider; + + private volatile SslBundle sslBundle; public SslServerCustomizer(Http2 http2, Ssl.ClientAuth clientAuth, SslBundle sslBundle) { this.http2 = http2; - this.clientAuth = clientAuth; + this.clientAuth = Ssl.ClientAuth.map(clientAuth, ClientAuth.NONE, ClientAuth.OPTIONAL, ClientAuth.REQUIRE); this.sslBundle = sslBundle; + this.sslProvider = createSslProvider(sslBundle); } @Override public HttpServer apply(HttpServer server) { - AbstractProtocolSslContextSpec sslContextSpec = createSslContextSpec(); - return server.secure((spec) -> spec.sslContext(sslContextSpec)); + return server.secure(this::applySecurity); } + private void applySecurity(SslContextSpec spec) { + spec.sslContext(this.sslProvider.getSslContext()) + .setSniAsyncMappings((domainName, promise) -> promise.setSuccess(this.sslProvider)); + } + + void updateSslBundle(SslBundle sslBundle) { + logger.debug("SSL Bundle has been updated, reloading SSL configuration"); + this.sslBundle = sslBundle; + this.sslProvider = createSslProvider(sslBundle); + } + + private SslProvider createSslProvider(SslBundle sslBundle) { + return SslProvider.builder().sslContext(createSslContextSpec(sslBundle)).build(); + } + + /** + * Factory method used to create an {@link AbstractProtocolSslContextSpec}. + * @return the {@link AbstractProtocolSslContextSpec} to use + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of + * {@link #createSslContextSpec(SslBundle)} + */ + @Deprecated(since = "3.2", forRemoval = true) protected AbstractProtocolSslContextSpec createSslContextSpec() { + return createSslContextSpec(this.sslBundle); + } + + /** + * Create an {@link AbstractProtocolSslContextSpec} for a given {@link SslBundle}. + * @param sslBundle the {@link SslBundle} to use + * @return an {@link AbstractProtocolSslContextSpec} instance + * @since 3.2.0 + */ + protected final AbstractProtocolSslContextSpec createSslContextSpec(SslBundle sslBundle) { AbstractProtocolSslContextSpec sslContextSpec = (this.http2 != null && this.http2.isEnabled()) - ? Http2SslContextSpec.forServer(this.sslBundle.getManagers().getKeyManagerFactory()) - : Http11SslContextSpec.forServer(this.sslBundle.getManagers().getKeyManagerFactory()); - sslContextSpec.configure((builder) -> { - builder.trustManager(this.sslBundle.getManagers().getTrustManagerFactory()); - SslOptions options = this.sslBundle.getOptions(); + ? Http2SslContextSpec.forServer(sslBundle.getManagers().getKeyManagerFactory()) + : Http11SslContextSpec.forServer(sslBundle.getManagers().getKeyManagerFactory()); + return sslContextSpec.configure((builder) -> { + builder.trustManager(sslBundle.getManagers().getTrustManagerFactory()); + SslOptions options = sslBundle.getOptions(); builder.protocols(options.getEnabledProtocols()); builder.ciphers(SslOptions.asSet(options.getCiphers())); - builder.clientAuth(org.springframework.boot.web.server.Ssl.ClientAuth.map(this.clientAuth, ClientAuth.NONE, - ClientAuth.OPTIONAL, ClientAuth.REQUIRE)); + builder.clientAuth(this.clientAuth); }); - return sslContextSpec; } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizer.java index 516c61db008..75601111c1d 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizer.java @@ -17,6 +17,7 @@ package org.springframework.boot.web.embedded.tomcat; import org.apache.catalina.connector.Connector; +import org.apache.commons.logging.Log; import org.apache.coyote.ProtocolHandler; import org.apache.coyote.http11.AbstractHttp11JsseProtocol; import org.apache.coyote.http11.Http11NioProtocol; @@ -33,48 +34,62 @@ import org.springframework.util.Assert; import org.springframework.util.StringUtils; /** - * {@link TomcatConnectorCustomizer} that configures SSL support on the given connector. + * Utility that configures SSL support on the given connector. * * @author Brian Clozel * @author Andy Wilkinson * @author Scott Frederick * @author Cyril Dangerville + * @author Moritz Halbritter */ -class SslConnectorCustomizer implements TomcatConnectorCustomizer { +class SslConnectorCustomizer { + + private final Log logger; private final ClientAuth clientAuth; - private final SslBundle sslBundle; + private final Connector connector; - SslConnectorCustomizer(ClientAuth clientAuth, SslBundle sslBundle) { + SslConnectorCustomizer(Log logger, Connector connector, ClientAuth clientAuth) { + this.logger = logger; this.clientAuth = clientAuth; - this.sslBundle = sslBundle; + this.connector = connector; } - @Override - public void customize(Connector connector) { - ProtocolHandler handler = connector.getProtocolHandler(); + void update(SslBundle updatedSslBundle) { + this.logger.debug("SSL Bundle has been updated, reloading SSL configuration"); + customize(updatedSslBundle); + } + + void customize(SslBundle sslBundle) { + ProtocolHandler handler = this.connector.getProtocolHandler(); Assert.state(handler instanceof AbstractHttp11JsseProtocol, "To use SSL, the connector's protocol handler must be an AbstractHttp11JsseProtocol subclass"); - configureSsl((AbstractHttp11JsseProtocol) handler); - connector.setScheme("https"); - connector.setSecure(true); + configureSsl(sslBundle, (AbstractHttp11JsseProtocol) handler); + this.connector.setScheme("https"); + this.connector.setSecure(true); } /** * Configure Tomcat's {@link AbstractHttp11JsseProtocol} for SSL. + * @param sslBundle the SSL bundle * @param protocol the protocol */ - void configureSsl(AbstractHttp11JsseProtocol protocol) { - SslBundleKey key = this.sslBundle.getKey(); - SslStoreBundle stores = this.sslBundle.getStores(); - SslOptions options = this.sslBundle.getOptions(); + private void configureSsl(SslBundle sslBundle, AbstractHttp11JsseProtocol protocol) { protocol.setSSLEnabled(true); SSLHostConfig sslHostConfig = new SSLHostConfig(); sslHostConfig.setHostName(protocol.getDefaultSSLHostConfigName()); - sslHostConfig.setSslProtocol(this.sslBundle.getProtocol()); - protocol.addSslHostConfig(sslHostConfig); configureSslClientAuth(sslHostConfig); + applySslBundle(sslBundle, protocol, sslHostConfig); + protocol.addSslHostConfig(sslHostConfig, true); + } + + private void applySslBundle(SslBundle sslBundle, AbstractHttp11JsseProtocol protocol, + SSLHostConfig sslHostConfig) { + SslBundleKey key = sslBundle.getKey(); + SslStoreBundle stores = sslBundle.getStores(); + SslOptions options = sslBundle.getOptions(); + sslHostConfig.setSslProtocol(sslBundle.getProtocol()); SSLHostConfigCertificate certificate = new SSLHostConfigCertificate(sslHostConfig, Type.UNDEFINED); String keystorePassword = (stores.getKeyStorePassword() != null) ? stores.getKeyStorePassword() : ""; certificate.setCertificateKeystorePassword(keystorePassword); @@ -89,17 +104,14 @@ class SslConnectorCustomizer implements TomcatConnectorCustomizer { String ciphers = StringUtils.arrayToCommaDelimitedString(options.getCiphers()); sslHostConfig.setCiphers(ciphers); } - configureEnabledProtocols(protocol); - configureSslStoreProvider(protocol, sslHostConfig, certificate); + configureSslStoreProvider(protocol, sslHostConfig, certificate, stores); + configureEnabledProtocols(sslHostConfig, options); } - private void configureEnabledProtocols(AbstractHttp11JsseProtocol protocol) { - SslOptions options = this.sslBundle.getOptions(); + private void configureEnabledProtocols(SSLHostConfig sslHostConfig, SslOptions options) { if (options.getEnabledProtocols() != null) { String enabledProtocols = StringUtils.arrayToDelimitedString(options.getEnabledProtocols(), "+"); - for (SSLHostConfig sslHostConfig : protocol.findSslHostConfigs()) { - sslHostConfig.setProtocols(enabledProtocols); - } + sslHostConfig.setProtocols(enabledProtocols); } } @@ -107,12 +119,11 @@ class SslConnectorCustomizer implements TomcatConnectorCustomizer { config.setCertificateVerification(ClientAuth.map(this.clientAuth, "none", "optional", "required")); } - protected void configureSslStoreProvider(AbstractHttp11JsseProtocol protocol, SSLHostConfig sslHostConfig, - SSLHostConfigCertificate certificate) { + private void configureSslStoreProvider(AbstractHttp11JsseProtocol protocol, SSLHostConfig sslHostConfig, + SSLHostConfigCertificate certificate, SslStoreBundle stores) { Assert.isInstanceOf(Http11NioProtocol.class, protocol, "SslStoreProvider can only be used with Http11NioProtocol"); try { - SslStoreBundle stores = this.sslBundle.getStores(); if (stores.getKeyStore() != null) { certificate.setCertificateKeystore(stores.getKeyStore()); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactory.java index ab31ecce739..97d903d398c 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactory.java @@ -35,6 +35,8 @@ import org.apache.catalina.connector.Connector; import org.apache.catalina.core.AprLifecycleListener; import org.apache.catalina.loader.WebappLoader; import org.apache.catalina.startup.Tomcat; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.apache.coyote.AbstractProtocol; import org.apache.coyote.ProtocolHandler; import org.apache.coyote.http2.Http2Protocol; @@ -57,11 +59,14 @@ import org.springframework.util.StringUtils; * * @author Brian Clozel * @author HaiTao Zhang + * @author Moritz Halbritter * @since 2.0.0 */ public class TomcatReactiveWebServerFactory extends AbstractReactiveWebServerFactory implements ConfigurableTomcatWebServerFactory { + private static final Log logger = LogFactory.getLog(TomcatReactiveWebServerFactory.class); + private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; /** @@ -224,7 +229,12 @@ public class TomcatReactiveWebServerFactory extends AbstractReactiveWebServerFac } private void customizeSsl(Connector connector) { - new SslConnectorCustomizer(getSsl().getClientAuth(), getSslBundle()).customize(connector); + SslConnectorCustomizer customizer = new SslConnectorCustomizer(logger, connector, getSsl().getClientAuth()); + customizer.customize(getSslBundle()); + String sslBundleName = getSsl().getBundle(); + if (StringUtils.hasText(sslBundleName)) { + getSslBundles().addBundleUpdateHandler(sslBundleName, customizer::update); + } } @Override diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.java index 7f05d87a115..31144f56a3a 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.java @@ -62,6 +62,8 @@ import org.apache.catalina.util.SessionConfig; import org.apache.catalina.webresources.AbstractResourceSet; import org.apache.catalina.webresources.EmptyResource; import org.apache.catalina.webresources.StandardRoot; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.apache.coyote.AbstractProtocol; import org.apache.coyote.ProtocolHandler; import org.apache.coyote.http2.Http2Protocol; @@ -103,6 +105,7 @@ import org.springframework.util.StringUtils; * @author EddĂș MelĂ©ndez * @author Christoffer Sawicki * @author Dawid Antecki + * @author Moritz Halbritter * @since 2.0.0 * @see #setPort(int) * @see #setContextLifecycleListeners(Collection) @@ -111,6 +114,8 @@ import org.springframework.util.StringUtils; public class TomcatServletWebServerFactory extends AbstractServletWebServerFactory implements ConfigurableTomcatWebServerFactory, ResourceLoaderAware { + private static final Log logger = LogFactory.getLog(TomcatServletWebServerFactory.class); + private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; private static final Set> NO_CLASSES = Collections.emptySet(); @@ -366,7 +371,12 @@ public class TomcatServletWebServerFactory extends AbstractServletWebServerFacto } private void customizeSsl(Connector connector) { - new SslConnectorCustomizer(getSsl().getClientAuth(), getSslBundle()).customize(connector); + SslConnectorCustomizer customizer = new SslConnectorCustomizer(logger, connector, getSsl().getClientAuth()); + customizer.customize(getSslBundle()); + String sslBundleName = getSsl().getBundle(); + if (StringUtils.hasText(sslBundleName)) { + getSslBundles().addBundleUpdateHandler(sslBundleName, customizer::update); + } } /** diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java index 4a23afd7533..21fa14527ae 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java @@ -146,6 +146,15 @@ public abstract class AbstractConfigurableWebServerFactory implements Configurab this.sslStoreProvider = sslStoreProvider; } + /** + * Return the configured {@link SslBundles}. + * @return the {@link SslBundles} or {@code null} + * @since 3.2.0 + */ + public SslBundles getSslBundles() { + return this.sslBundles; + } + @Override public void setSslBundles(SslBundles sslBundles) { this.sslBundles = sslBundles; diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslBundleRegistryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslBundleRegistryTests.java index d8cf034eef5..fdd2c882459 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslBundleRegistryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslBundleRegistryTests.java @@ -16,26 +16,43 @@ package org.springframework.boot.ssl; +import java.util.concurrent.atomic.AtomicReference; + +import org.awaitility.Awaitility; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.testsupport.system.CapturedOutput; +import org.springframework.boot.testsupport.system.OutputCaptureExtension; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; /** * Tests for {@link DefaultSslBundleRegistry}. * * @author Phillip Webb + * @author Moritz Halbritter */ +@ExtendWith(OutputCaptureExtension.class) class DefaultSslBundleRegistryTests { - private SslBundle bundle1 = mock(SslBundle.class); + private final SslBundle bundle1 = mock(SslBundle.class); - private SslBundle bundle2 = mock(SslBundle.class); + private final SslBundle bundle2 = mock(SslBundle.class); - private DefaultSslBundleRegistry registry = new DefaultSslBundleRegistry(); + private DefaultSslBundleRegistry registry; + + @BeforeEach + void setUp() { + this.registry = new DefaultSslBundleRegistry(); + } @Test void createWithNameAndBundleRegistersBundle() { @@ -89,4 +106,29 @@ class DefaultSslBundleRegistryTests { assertThat(this.registry.getBundle("test2")).isSameAs(this.bundle2); } + @Test + void updateBundleShouldNotifyUpdateHandlers() { + AtomicReference updatedBundle = new AtomicReference<>(); + this.registry.registerBundle("test1", this.bundle1); + this.registry.addBundleUpdateHandler("test1", updatedBundle::set); + this.registry.updateBundle("test1", this.bundle2); + Awaitility.await().untilAtomic(updatedBundle, Matchers.equalTo(this.bundle2)); + } + + @Test + void shouldFailIfUpdatingNonRegisteredBundle() { + assertThatThrownBy(() -> this.registry.updateBundle("dummy", this.bundle1)) + .isInstanceOf(NoSuchSslBundleException.class) + .hasMessageContaining("'dummy'"); + } + + @Test + void shouldLogIfUpdatingBundleWithoutListeners(CapturedOutput output) { + this.registry.registerBundle("test1", this.bundle1); + this.registry.getBundle("test1"); + this.registry.updateBundle("test1", this.bundle2); + assertThat(output).contains( + "SSL bundle 'test1' has been updated but may be in use by a technology that doesn't support SSL reloading"); + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreBundleTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreBundleTests.java index cdb30bbc50e..84c0f408cd6 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreBundleTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreBundleTests.java @@ -35,6 +35,62 @@ import static org.assertj.core.api.Assertions.assertThatIllegalStateException; */ class PemSslStoreBundleTests { + private static final String CERTIFICATE = """ + -----BEGIN CERTIFICATE----- + MIIDqzCCApOgAwIBAgIIFMqbpqvipw0wDQYJKoZIhvcNAQELBQAwbDELMAkGA1UE + BhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExEjAQBgNVBAcTCVBhbG8gQWx0bzEP + MA0GA1UEChMGVk13YXJlMQ8wDQYDVQQLEwZTcHJpbmcxEjAQBgNVBAMTCWxvY2Fs + aG9zdDAgFw0yMzA1MDUxMTI2NThaGA8yMTIzMDQxMTExMjY1OFowbDELMAkGA1UE + BhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExEjAQBgNVBAcTCVBhbG8gQWx0bzEP + MA0GA1UEChMGVk13YXJlMQ8wDQYDVQQLEwZTcHJpbmcxEjAQBgNVBAMTCWxvY2Fs + aG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAPwHWxoE3xjRmNdD + +m+e/aFlr5wEGQUdWSDD613OB1w7kqO/audEp3c6HxDB3GPcEL0amJwXgY6CQMYu + sythuZX/EZSc2HdilTBu/5T+mbdWe5JkKThpiA0RYeucQfKuB7zv4ypioa4wiR4D + nPsZXjg95OF8pCzYEssv8wT49v+M3ohWUgfF0FPlMFCSo0YVTuzB1mhDlWKq/jhQ + 11WpTmk/dQX+l6ts6bYIcJt4uItG+a68a4FutuSjZdTAE0f5SOYRBpGH96mjLwEP + fW8ZjzvKb9g4R2kiuoPxvCDs1Y/8V2yvKqLyn5Tx9x/DjFmOi0DRK/TgELvNceCb + UDJmhXMCAwEAAaNPME0wHQYDVR0OBBYEFMBIGU1nwix5RS3O5hGLLoMdR1+NMCwG + A1UdEQQlMCOCCWxvY2FsaG9zdIcQAAAAAAAAAAAAAAAAAAAAAYcEfwAAATANBgkq + hkiG9w0BAQsFAAOCAQEAhepfJgTFvqSccsT97XdAZfvB0noQx5NSynRV8NWmeOld + hHP6Fzj6xCxHSYvlUfmX8fVP9EOAuChgcbbuTIVJBu60rnDT21oOOnp8FvNonCV6 + gJ89sCL7wZ77dw2RKIeUFjXXEV3QJhx2wCOVmLxnJspDoKFIEVjfLyiPXKxqe/6b + dG8zzWDZ6z+M2JNCtVoOGpljpHqMPCmbDktncv6H3dDTZ83bmLj1nbpOU587gAJ8 + fl1PiUDyPRIl2cnOJd+wCHKsyym/FL7yzk0OSEZ81I92LpGd/0b2Ld3m/bpe+C4Z + ILzLXTnC6AhrLcDc9QN/EO+BiCL52n7EplNLtSn1LQ== + -----END CERTIFICATE----- + """.strip(); + + private static final String PRIVATE_KEY = """ + -----BEGIN PRIVATE KEY----- + MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD8B1saBN8Y0ZjX + Q/pvnv2hZa+cBBkFHVkgw+tdzgdcO5Kjv2rnRKd3Oh8Qwdxj3BC9GpicF4GOgkDG + LrMrYbmV/xGUnNh3YpUwbv+U/pm3VnuSZCk4aYgNEWHrnEHyrge87+MqYqGuMIke + A5z7GV44PeThfKQs2BLLL/ME+Pb/jN6IVlIHxdBT5TBQkqNGFU7swdZoQ5Viqv44 + UNdVqU5pP3UF/perbOm2CHCbeLiLRvmuvGuBbrbko2XUwBNH+UjmEQaRh/epoy8B + D31vGY87ym/YOEdpIrqD8bwg7NWP/Fdsryqi8p+U8fcfw4xZjotA0Sv04BC7zXHg + m1AyZoVzAgMBAAECggEAfEqiZqANaF+BqXQIb4Dw42ZTJzWsIyYYnPySOGZRoe5t + QJ03uwtULYv34xtANe1DQgd6SMyc46ugBzzjtprQ3ET5Jhn99U6kdcjf+dpf85dO + hOEppP0CkDNI39nleinSfh6uIOqYgt/D143/nqQhn8oCdSOzkbwT9KnWh1bC9T7I + vFjGfElvt1/xl88qYgrWgYLgXaencNGgiv/4/M0FNhiHEGsVC7SCu6kapC/WIQpE + 5IdV+HR+tiLoGZhXlhqorY7QC4xKC4wwafVSiFxqDOQAuK+SMD4TCEv0Aop+c+SE + YBigVTmgVeJkjK7IkTEhKkAEFmRF5/5w+bZD9FhTNQKBgQD+4fNG1ChSU8RdizZT + 5dPlDyAxpETSCEXFFVGtPPh2j93HDWn7XugNyjn5FylTH507QlabC+5wZqltdIjK + GRB5MIinQ9/nR2fuwGc9s+0BiSEwNOUB1MWm7wWL/JUIiKq6sTi6sJIfsYg79zco + qxl5WE94aoINx9Utq1cdWhwJTQKBgQD9IjPksd4Jprz8zMrGLzR8k1gqHyhv24qY + EJ7jiHKKAP6xllTUYwh1IBSL6w2j5lfZPpIkb4Jlk2KUoX6fN81pWkBC/fTBUSIB + EHM9bL51+yKEYUbGIy/gANuRbHXsWg3sjUsFTNPN4hGTFk3w2xChCyl/f5us8Lo8 + Z633SNdpvwKBgQCGyDU9XzNzVZihXtx7wS0sE7OSjKtX5cf/UCbA1V0OVUWR3SYO + J0HPCQFfF0BjFHSwwYPKuaR9C8zMdLNhK5/qdh/NU7czNi9fsZ7moh7SkRFbzJzN + OxbKD9t/CzJEMQEXeF/nWTfsSpUgILqqZtAxuuFLbAcaAnJYlCKdAumQgQKBgQCK + mqjJh68pn7gJwGUjoYNe1xtGbSsqHI9F9ovZ0MPO1v6e5M7sQJHH+Fnnxzv/y8e8 + d6tz8e73iX1IHymDKv35uuZHCGF1XOR+qrA/KQUc+vcKf21OXsP/JtkTRs1HLoRD + S5aRf2DWcfvniyYARSNU2xTM8GWgi2ueWbMDHUp+ZwKBgA/swC+K+Jg5DEWm6Sau + e6y+eC6S+SoXEKkI3wf7m9aKoZo0y+jh8Gas6gratlc181pSM8O3vZG0n19b493I + apCFomMLE56zEzvyzfpsNhFhk5MBMCn0LPyzX6MiynRlGyWIj0c99fbHI3pOMufP + WgmVLTZ8uDcSW1MbdUCwFSk5 + -----END PRIVATE KEY----- + """.strip(); + private static final char[] EMPTY_KEY_PASSWORD = new char[] {}; @Test @@ -99,6 +155,16 @@ class PemSslStoreBundleTests { assertThat(bundle.getTrustStore()).satisfies(storeContainingCertAndKey("ssl")); } + @Test + void whenHasEmbeddedKeyStoreDetailsAndTrustStoreDetails() { + PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate(CERTIFICATE).withPrivateKey(PRIVATE_KEY); + PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate(CERTIFICATE) + .withPrivateKey(PRIVATE_KEY); + PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails); + assertThat(bundle.getKeyStore()).satisfies(storeContainingCertAndKey("ssl")); + assertThat(bundle.getTrustStore()).satisfies(storeContainingCertAndKey("ssl")); + } + @Test void whenHasKeyStoreDetailsAndTrustStoreDetailsAndAlias() { PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem") diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactoryTests.java index 3694ee942d8..d6700818710 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactoryTests.java @@ -34,6 +34,11 @@ import reactor.netty.DisposableServer; import reactor.netty.http.server.HttpServer; import reactor.test.StepVerifier; +import org.springframework.boot.ssl.DefaultSslBundleRegistry; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.ssl.pem.PemSslStoreBundle; +import org.springframework.boot.ssl.pem.PemSslStoreDetails; import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactory; import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactoryTests; import org.springframework.boot.web.server.PortInUseException; @@ -59,6 +64,7 @@ import static org.mockito.Mockito.mock; * * @author Brian Clozel * @author Chris Bono + * @author Moritz Halbritter */ class NettyReactiveWebServerFactoryTests extends AbstractReactiveWebServerFactoryTests { @@ -132,6 +138,16 @@ class NettyReactiveWebServerFactoryTests extends AbstractReactiveWebServerFactor StepVerifier.create(result).expectNext("Hello World").expectComplete().verify(Duration.ofSeconds(30)); } + @Test + void whenSslBundleIsUpdatedThenSslIsReloaded() { + DefaultSslBundleRegistry bundles = new DefaultSslBundleRegistry("bundle1", createSslBundle("1.key", "1.crt")); + Mono result = testSslWithBundle(bundles, "bundle1"); + StepVerifier.create(result).expectNext("Hello World").expectComplete().verify(Duration.ofSeconds(30)); + bundles.updateBundle("bundle1", createSslBundle("2.key", "2.crt")); + Mono result2 = executeSslRequest(); + StepVerifier.create(result2).expectNext("Hello World").expectComplete().verify(Duration.ofSeconds(30)); + } + @Test void whenServerIsShuttingDownGracefullyThenNewConnectionsCannotBeMade() { NettyReactiveWebServerFactory factory = getFactory(); @@ -161,7 +177,7 @@ class NettyReactiveWebServerFactoryTests extends AbstractReactiveWebServerFactor protected void startedLogMessageWithMultiplePorts() { } - protected Mono testSslWithAlias(String alias) { + private Mono testSslWithAlias(String alias) { String keyStore = "classpath:test.jks"; String keyPassword = "password"; NettyReactiveWebServerFactory factory = getFactory(); @@ -172,6 +188,19 @@ class NettyReactiveWebServerFactoryTests extends AbstractReactiveWebServerFactor factory.setSsl(ssl); this.webServer = factory.getWebServer(new EchoHandler()); this.webServer.start(); + return executeSslRequest(); + } + + private Mono testSslWithBundle(SslBundles sslBundles, String bundle) { + NettyReactiveWebServerFactory factory = getFactory(); + factory.setSslBundles(sslBundles); + factory.setSsl(Ssl.forBundle(bundle)); + this.webServer = factory.getWebServer(new EchoHandler()); + this.webServer.start(); + return executeSslRequest(); + } + + private Mono executeSslRequest() { ReactorClientHttpConnector connector = buildTrustAllSslConnector(); WebClient client = WebClient.builder() .baseUrl("https://localhost:" + this.webServer.getPort()) @@ -200,6 +229,13 @@ class NettyReactiveWebServerFactoryTests extends AbstractReactiveWebServerFactor throw new UnsupportedOperationException("Reactor Netty does not support multiple ports"); } + private static SslBundle createSslBundle(String key, String certificate) { + return SslBundle.of(new PemSslStoreBundle( + new PemSslStoreDetails(null, "classpath:org/springframework/boot/web/embedded/netty/" + certificate, + "classpath:org/springframework/boot/web/embedded/netty/" + key), + null)); + } + static class NoPortNettyReactiveWebServerFactory extends NettyReactiveWebServerFactory { NoPortNettyReactiveWebServerFactory(int port) { diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizerTests.java index 53b05891a7f..a845ffb1e7e 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizerTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizerTests.java @@ -27,6 +27,8 @@ import java.util.Set; import org.apache.catalina.LifecycleState; import org.apache.catalina.connector.Connector; import org.apache.catalina.startup.Tomcat; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.apache.tomcat.util.net.SSLHostConfig; import org.apache.tomcat.util.net.SSLHostConfigCertificate; import org.junit.jupiter.api.AfterEach; @@ -65,6 +67,8 @@ import static org.mockito.Mockito.mock; @MockPkcs11Security class SslConnectorCustomizerTests { + private final Log logger = LogFactory.getLog(SslConnectorCustomizerTests.class); + private Tomcat tomcat; @BeforeEach @@ -87,10 +91,9 @@ class SslConnectorCustomizerTests { ssl.setKeyStore("classpath:test.jks"); ssl.setKeyStorePassword("secret"); ssl.setCiphers(new String[] { "ALPHA", "BRAVO", "CHARLIE" }); - SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(), - WebServerSslBundle.get(ssl)); Connector connector = this.tomcat.getConnector(); - customizer.customize(connector); + SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth()); + customizer.customize(WebServerSslBundle.get(ssl)); this.tomcat.start(); SSLHostConfig[] sslHostConfigs = connector.getProtocolHandler().findSslHostConfigs(); assertThat(sslHostConfigs[0].getCiphers()).isEqualTo("ALPHA:BRAVO:CHARLIE"); @@ -103,10 +106,9 @@ class SslConnectorCustomizerTests { ssl.setKeyStore("src/test/resources/test.jks"); ssl.setEnabledProtocols(new String[] { "TLSv1.1", "TLSv1.2" }); ssl.setCiphers(new String[] { "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", "BRAVO" }); - SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(), - WebServerSslBundle.get(ssl)); Connector connector = this.tomcat.getConnector(); - customizer.customize(connector); + SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth()); + customizer.customize(WebServerSslBundle.get(ssl)); this.tomcat.start(); SSLHostConfig sslHostConfig = connector.getProtocolHandler().findSslHostConfigs()[0]; assertThat(sslHostConfig.getSslProtocol()).isEqualTo("TLS"); @@ -120,10 +122,9 @@ class SslConnectorCustomizerTests { ssl.setKeyStore("src/test/resources/test.jks"); ssl.setEnabledProtocols(new String[] { "TLSv1.2" }); ssl.setCiphers(new String[] { "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", "BRAVO" }); - SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(), - WebServerSslBundle.get(ssl)); Connector connector = this.tomcat.getConnector(); - customizer.customize(connector); + SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth()); + customizer.customize(WebServerSslBundle.get(ssl)); this.tomcat.start(); SSLHostConfig sslHostConfig = connector.getProtocolHandler().findSslHostConfigs()[0]; assertThat(sslHostConfig.getSslProtocol()).isEqualTo("TLS"); @@ -139,10 +140,9 @@ class SslConnectorCustomizerTests { SslStoreProvider sslStoreProvider = mock(SslStoreProvider.class); KeyStore keyStore = loadStore(); given(sslStoreProvider.getKeyStore()).willReturn(keyStore); - SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(), - WebServerSslBundle.get(ssl, null, sslStoreProvider)); Connector connector = this.tomcat.getConnector(); - customizer.customize(connector); + SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth()); + customizer.customize(WebServerSslBundle.get(ssl, null, sslStoreProvider)); this.tomcat.start(); SSLHostConfig sslHostConfig = connector.getProtocolHandler().findSslHostConfigs()[0]; SSLHostConfig sslHostConfigWithDefaults = new SSLHostConfig(); @@ -161,10 +161,9 @@ class SslConnectorCustomizerTests { SslStoreProvider sslStoreProvider = mock(SslStoreProvider.class); KeyStore trustStore = loadStore(); given(sslStoreProvider.getTrustStore()).willReturn(trustStore); - SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(), - WebServerSslBundle.get(ssl, null, sslStoreProvider)); Connector connector = this.tomcat.getConnector(); - customizer.customize(connector); + SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth()); + customizer.customize(WebServerSslBundle.get(ssl, null, sslStoreProvider)); this.tomcat.start(); SSLHostConfig sslHostConfig = connector.getProtocolHandler().findSslHostConfigs()[0]; assertThat(sslHostConfig.getTruststore()).isEqualTo(trustStore); @@ -180,10 +179,9 @@ class SslConnectorCustomizerTests { SslStoreProvider sslStoreProvider = mock(SslStoreProvider.class); given(sslStoreProvider.getTrustStore()).willReturn(loadStore()); given(sslStoreProvider.getKeyStore()).willReturn(loadStore()); - SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(), - WebServerSslBundle.get(ssl, null, sslStoreProvider)); Connector connector = this.tomcat.getConnector(); - customizer.customize(connector); + SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth()); + customizer.customize(WebServerSslBundle.get(ssl, null, sslStoreProvider)); this.tomcat.start(); assertThat(connector.getState()).isEqualTo(LifecycleState.STARTED); assertThat(output).doesNotContain("Password verification failed"); @@ -192,9 +190,9 @@ class SslConnectorCustomizerTests { @Test void customizeWhenSslIsEnabledWithNoKeyStoreAndNotPkcs11ThrowsException() { assertThatIllegalStateException().isThrownBy(() -> { - SslConnectorCustomizer customizer = new SslConnectorCustomizer(Ssl.ClientAuth.NONE, - WebServerSslBundle.get(new Ssl())); - customizer.customize(this.tomcat.getConnector()); + SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, this.tomcat.getConnector(), + Ssl.ClientAuth.NONE); + customizer.customize(WebServerSslBundle.get(new Ssl())); }).withMessageContaining("SSL is enabled but no trust material is configured"); } @@ -206,9 +204,9 @@ class SslConnectorCustomizerTests { ssl.setKeyStore("src/test/resources/test.jks"); ssl.setKeyPassword("password"); assertThatIllegalStateException().isThrownBy(() -> { - SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(), - WebServerSslBundle.get(ssl)); - customizer.customize(this.tomcat.getConnector()); + SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, this.tomcat.getConnector(), + ssl.getClientAuth()); + customizer.customize(WebServerSslBundle.get(ssl)); }).withMessageContaining("must be empty or null for PKCS11 hardware key stores"); } @@ -218,9 +216,9 @@ class SslConnectorCustomizerTests { ssl.setKeyStoreType("PKCS11"); ssl.setKeyStoreProvider(MockPkcs11SecurityProvider.NAME); ssl.setKeyStorePassword("1234"); - SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(), - WebServerSslBundle.get(ssl)); - assertThatNoException().isThrownBy(() -> customizer.customize(this.tomcat.getConnector())); + SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, this.tomcat.getConnector(), + ssl.getClientAuth()); + assertThatNoException().isThrownBy(() -> customizer.customize(WebServerSslBundle.get(ssl))); } private KeyStore loadStore() throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException { diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactoryTests.java index e619c9120cc..5dafd69bc45 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactoryTests.java @@ -32,6 +32,9 @@ import java.util.concurrent.atomic.AtomicReference; import javax.naming.InitialContext; import javax.naming.NamingException; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; import jakarta.servlet.MultipartConfigElement; import jakarta.servlet.ServletContext; @@ -60,8 +63,11 @@ import org.apache.coyote.http11.AbstractHttp11Protocol; import org.apache.hc.client5.http.HttpHostConnectException; import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.client5.http.ssl.TrustSelfSignedStrategy; import org.apache.hc.core5.http.HttpResponse; import org.apache.hc.core5.http.NoHttpResponseException; +import org.apache.hc.core5.ssl.SSLContextBuilder; import org.apache.jasper.servlet.JspServlet; import org.apache.tomcat.JarScanFilter; import org.apache.tomcat.JarScanType; @@ -73,9 +79,11 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.mockito.InOrder; +import org.springframework.boot.ssl.DefaultSslBundleRegistry; import org.springframework.boot.testsupport.system.CapturedOutput; import org.springframework.boot.web.server.PortInUseException; import org.springframework.boot.web.server.Shutdown; +import org.springframework.boot.web.server.Ssl; import org.springframework.boot.web.server.WebServerException; import org.springframework.boot.web.servlet.server.AbstractServletWebServerFactory; import org.springframework.boot.web.servlet.server.AbstractServletWebServerFactoryTests; @@ -87,6 +95,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.util.FileSystemUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -107,6 +116,7 @@ import static org.mockito.Mockito.mock; * @author Phillip Webb * @author Dave Syer * @author Stephane Nicoll + * @author Moritz Halbritter */ class TomcatServletWebServerFactoryTests extends AbstractServletWebServerFactoryTests { @@ -636,6 +646,30 @@ class TomcatServletWebServerFactoryTests extends AbstractServletWebServerFactory this.webServer.stop(); } + @Test + void shouldUpdateSslWhenReloadingSslBundles() throws Exception { + TomcatServletWebServerFactory factory = getFactory(); + addTestTxtFile(factory); + DefaultSslBundleRegistry bundles = new DefaultSslBundleRegistry("test", + createPemSslBundle("classpath:org/springframework/boot/web/embedded/tomcat/1.crt", + "classpath:org/springframework/boot/web/embedded/tomcat/1.key")); + factory.setSslBundles(bundles); + factory.setSsl(Ssl.forBundle("test")); + this.webServer = factory.getWebServer(); + this.webServer.start(); + RememberingHostnameVerifier verifier = new RememberingHostnameVerifier(); + SSLConnectionSocketFactory socketFactory = new SSLConnectionSocketFactory( + new SSLContextBuilder().loadTrustMaterial(null, new TrustSelfSignedStrategy()).build(), verifier); + HttpComponentsClientHttpRequestFactory requestFactory = createHttpComponentsRequestFactory(socketFactory); + assertThat(getResponse(getLocalUrl("https", "/test.txt"), requestFactory)).isEqualTo("test"); + assertThat(verifier.getLastPrincipal()).isEqualTo("CN=1"); + requestFactory = createHttpComponentsRequestFactory(socketFactory); + bundles.updateBundle("test", createPemSslBundle("classpath:org/springframework/boot/web/embedded/tomcat/2.crt", + "classpath:org/springframework/boot/web/embedded/tomcat/2.key")); + assertThat(getResponse(getLocalUrl("https", "/test.txt"), requestFactory)).isEqualTo("test"); + assertThat(verifier.getLastPrincipal()).isEqualTo("CN=2"); + } + @Override protected JspServlet getJspServlet() throws ServletException { Tomcat tomcat = ((TomcatWebServer) this.webServer).getTomcat(); @@ -694,4 +728,25 @@ class TomcatServletWebServerFactoryTests extends AbstractServletWebServerFactory return ((TomcatWebServer) this.webServer).getStartedLogMessage(); } + private static class RememberingHostnameVerifier implements HostnameVerifier { + + private volatile String lastPrincipal; + + @Override + public boolean verify(String hostname, SSLSession session) { + try { + this.lastPrincipal = session.getPeerPrincipal().getName(); + } + catch (SSLPeerUnverifiedException ex) { + throw new RuntimeException(ex); + } + return true; + } + + String getLastPrincipal() { + return this.lastPrincipal; + } + + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactoryTests.java index 3e5a79f00bc..d7236bee107 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactoryTests.java @@ -789,7 +789,7 @@ public abstract class AbstractServletWebServerFactoryTests { return new JksSslStoreDetails(getStoreType(location), null, location, "secret"); } - private SslBundle createPemSslBundle(String cert, String privateKey) { + protected SslBundle createPemSslBundle(String cert, String privateKey) { PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate(cert).withPrivateKey(privateKey); PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate(cert); SslStoreBundle stores = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails); @@ -807,14 +807,13 @@ public abstract class AbstractServletWebServerFactoryTests { assertThat(getResponse(getLocalUrl("https", "/hello"), requestFactory)).contains("scheme=https"); } - private HttpComponentsClientHttpRequestFactory createHttpComponentsRequestFactory( + protected HttpComponentsClientHttpRequestFactory createHttpComponentsRequestFactory( SSLConnectionSocketFactory socketFactory) { PoolingHttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder.create() .setSSLSocketFactory(socketFactory) .build(); HttpClient httpClient = this.httpClientBuilder.get().setConnectionManager(connectionManager).build(); - HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient); - return requestFactory; + return new HttpComponentsClientHttpRequestFactory(httpClient); } private String getStoreType(String keyStore) { @@ -1457,7 +1456,7 @@ public abstract class AbstractServletWebServerFactoryTests { protected abstract Charset getCharset(Locale locale); - private void addTestTxtFile(AbstractServletWebServerFactory factory) throws IOException { + protected void addTestTxtFile(AbstractServletWebServerFactory factory) throws IOException { FileCopyUtils.copy("test", new FileWriter(new File(this.tempDir, "test.txt"))); factory.setDocumentRoot(this.tempDir); factory.setRegisterDefaultServlet(true); diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/1.crt b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/1.crt new file mode 100644 index 00000000000..dd4be7410d6 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/1.crt @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBLjCB4aADAgECAhQ25wrNnapZEkFc8kgf5NDHXKxnTzAFBgMrZXAwDDEKMAgG +A1UEAwwBMTAgFw0yMzEwMTAwODU1MTJaGA8yMTIzMDkxNjA4NTUxMlowDDEKMAgG +A1UEAwwBMTAqMAUGAytlcAMhAOyxNxHzcNj7xTkcjVLI09sYUGUGIvdV5s0YWXT8 +XAiwo1MwUTAdBgNVHQ4EFgQUmm23oLIu5MgdBb/snZSuE+MrRZ0wHwYDVR0jBBgw +FoAUmm23oLIu5MgdBb/snZSuE+MrRZ0wDwYDVR0TAQH/BAUwAwEB/zAFBgMrZXAD +QQA2KMpIyySC8u4onW2MVW1iK2dJJZbMRaNMLlQuE+ZIHQLwflYW4sH/Pp76pboc +QhqKXcO7xH7f2tD5hE2izcUB +-----END CERTIFICATE----- diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/1.key new file mode 100644 index 00000000000..712fa35133c --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/1.key @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIJb1A+i5bmilBD9mUbhk1oFVI6FAZQGnhduv7xV6WWEc +-----END PRIVATE KEY----- diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/2.crt b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/2.crt new file mode 100644 index 00000000000..7c13395e0a5 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/2.crt @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBLjCB4aADAgECAhR4TMDk3qg5sKREp16lEHR3bV3M9zAFBgMrZXAwDDEKMAgG +A1UEAwwBMjAgFw0yMzEwMTAwODU1MjBaGA8yMTIzMDkxNjA4NTUyMFowDDEKMAgG +A1UEAwwBMjAqMAUGAytlcAMhADPft6hzyCjHCe5wSprChuuO/CuPIJ2t+l4roS1D +43/wo1MwUTAdBgNVHQ4EFgQUfrRibAWml4Ous4kpnBIggM2xnLcwHwYDVR0jBBgw +FoAUfrRibAWml4Ous4kpnBIggM2xnLcwDwYDVR0TAQH/BAUwAwEB/zAFBgMrZXAD +QQC/MOclal2Cp0B3kmaLbK0M8mapclIOJa78hzBkqPA3URClAF2GmF187wHqi7qV ++xZ+KWv26pLJR46vk8Kc6ZIO +-----END CERTIFICATE----- diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/2.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/2.key new file mode 100644 index 00000000000..9917897564b --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/2.key @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEICxhres2Z2lICm7/isnm+2iNR12GmgG7KK86BNDZDeIF +-----END PRIVATE KEY----- diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/1.crt b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/1.crt new file mode 100644 index 00000000000..dd4be7410d6 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/1.crt @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBLjCB4aADAgECAhQ25wrNnapZEkFc8kgf5NDHXKxnTzAFBgMrZXAwDDEKMAgG +A1UEAwwBMTAgFw0yMzEwMTAwODU1MTJaGA8yMTIzMDkxNjA4NTUxMlowDDEKMAgG +A1UEAwwBMTAqMAUGAytlcAMhAOyxNxHzcNj7xTkcjVLI09sYUGUGIvdV5s0YWXT8 +XAiwo1MwUTAdBgNVHQ4EFgQUmm23oLIu5MgdBb/snZSuE+MrRZ0wHwYDVR0jBBgw +FoAUmm23oLIu5MgdBb/snZSuE+MrRZ0wDwYDVR0TAQH/BAUwAwEB/zAFBgMrZXAD +QQA2KMpIyySC8u4onW2MVW1iK2dJJZbMRaNMLlQuE+ZIHQLwflYW4sH/Pp76pboc +QhqKXcO7xH7f2tD5hE2izcUB +-----END CERTIFICATE----- diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/1.key new file mode 100644 index 00000000000..712fa35133c --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/1.key @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIJb1A+i5bmilBD9mUbhk1oFVI6FAZQGnhduv7xV6WWEc +-----END PRIVATE KEY----- diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/2.crt b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/2.crt new file mode 100644 index 00000000000..7c13395e0a5 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/2.crt @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBLjCB4aADAgECAhR4TMDk3qg5sKREp16lEHR3bV3M9zAFBgMrZXAwDDEKMAgG +A1UEAwwBMjAgFw0yMzEwMTAwODU1MjBaGA8yMTIzMDkxNjA4NTUyMFowDDEKMAgG +A1UEAwwBMjAqMAUGAytlcAMhADPft6hzyCjHCe5wSprChuuO/CuPIJ2t+l4roS1D +43/wo1MwUTAdBgNVHQ4EFgQUfrRibAWml4Ous4kpnBIggM2xnLcwHwYDVR0jBBgw +FoAUfrRibAWml4Ous4kpnBIggM2xnLcwDwYDVR0TAQH/BAUwAwEB/zAFBgMrZXAD +QQC/MOclal2Cp0B3kmaLbK0M8mapclIOJa78hzBkqPA3URClAF2GmF187wHqi7qV ++xZ+KWv26pLJR46vk8Kc6ZIO +-----END CERTIFICATE----- diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/2.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/2.key new file mode 100644 index 00000000000..9917897564b --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/2.key @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEICxhres2Z2lICm7/isnm+2iNR12GmgG7KK86BNDZDeIF +-----END PRIVATE KEY-----