From c9e45952b0fbc6c481aa2ff3a4a07d0f21c7c633 Mon Sep 17 00:00:00 2001 From: Moritz Halbritter Date: Mon, 9 Oct 2023 14:05:12 +0200 Subject: [PATCH] Implement SSL hot reload --- gradle.properties | 2 +- .../boot/autoconfigure/ssl/FileWatcher.java | 302 ++++++++++++++++++ .../ssl/JksSslBundleProperties.java | 13 + .../ssl/PemSslBundleProperties.java | 18 ++ .../ssl/SslAutoConfiguration.java | 22 +- .../ssl/SslBundleProperties.java | 2 +- .../boot/autoconfigure/ssl/SslProperties.java | 41 +++ .../ssl/SslPropertiesBundleRegistrar.java | 105 +++++- .../autoconfigure/ssl/FileWatcherTests.java | 179 +++++++++++ .../SslPropertiesBundleRegistrarTests.java | 172 ++++++++++ .../boot/ssl/DefaultSslBundleRegistry.java | 59 ++++ .../boot/ssl/SslBundleRegistry.java | 9 + .../springframework/boot/ssl/SslBundles.java | 14 + .../boot/ssl/pem/PemContent.java | 64 ---- .../boot/ssl/pem/PemSslStoreBundle.java | 29 +- .../boot/ssl/pem/PemSslStoreDetails.java | 43 +++ .../netty/NettyReactiveWebServerFactory.java | 15 +- .../web/embedded/netty/NettyWebServer.java | 6 +- .../embedded/netty/SslServerCustomizer.java | 25 +- .../tomcat/SslConnectorCustomizer.java | 31 +- .../TomcatReactiveWebServerFactory.java | 16 +- .../tomcat/TomcatServletWebServerFactory.java | 16 +- .../AbstractConfigurableWebServerFactory.java | 12 + .../boot/web/server/WebServerSslBundle.java | 22 +- .../ssl/DefaultSslBundleRegistryTests.java | 47 ++- .../boot/ssl/pem/PemSslStoreBundleTests.java | 66 ++++ ...ests.java => PemSslStoreDetailsTests.java} | 39 +-- .../NettyReactiveWebServerFactoryTests.java | 38 ++- .../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 + 38 files changed, 1380 insertions(+), 139 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 delete mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java rename spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/{PemContentTests.java => PemSslStoreDetailsTests.java} (65%) 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/gradle.properties b/gradle.properties index b0ca86da74..fe2d91b579 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,6 +7,6 @@ org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 kotlinVersion=1.9.10 nativeBuildToolsVersion=0.9.27 springFrameworkVersion=6.1.0-SNAPSHOT -tomcatVersion=10.1.13 +tomcatVersion=10.1.14 kotlin.stdlib.default.dependency=false 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 0000000000..88fe7764f4 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/FileWatcher.java @@ -0,0 +1,302 @@ +/* + * 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.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.WatchEvent.Kind; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +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 + */ +class FileWatcher implements AutoCloseable { + + private static final Log logger = LogFactory.getLog(FileWatcher.class); + + private final String threadName; + + private final Duration quietPeriod; + + private final Object lifecycleLock = new Object(); + + private final Map> registrations = new ConcurrentHashMap<>(); + + private volatile WatchService watchService; + + private Thread thread; + + private boolean running = false; + + FileWatcher(String threadName, Duration quietPeriod) { + Assert.notNull(threadName, "threadName must not be null"); + Assert.notNull(quietPeriod, "quietPeriod must not be null"); + this.threadName = threadName; + this.quietPeriod = quietPeriod; + } + + void watch(Set paths, Callback callback) { + Assert.notNull(callback, "callback must not be null"); + Assert.notNull(paths, "paths must not be null"); + if (paths.isEmpty()) { + return; + } + startIfNecessary(); + try { + registerWatchables(callback, paths, this.watchService); + } + catch (IOException ex) { + throw new UncheckedIOException("Failed to register paths for watching: " + paths, ex); + } + } + + void stop() { + synchronized (this.lifecycleLock) { + if (!this.running) { + return; + } + this.running = false; + this.thread.interrupt(); + try { + this.thread.join(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + this.thread = null; + this.watchService = null; + this.registrations.clear(); + } + } + + private void startIfNecessary() { + synchronized (this.lifecycleLock) { + if (this.running) { + return; + } + CountDownLatch started = new CountDownLatch(1); + this.thread = new Thread(() -> this.threadMain(started)); + this.thread.setName(this.threadName); + this.thread.setDaemon(true); + this.thread.setUncaughtExceptionHandler(this::onThreadException); + this.running = true; + this.thread.start(); + try { + started.await(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + } + + private void threadMain(CountDownLatch started) { + logger.debug("Watch thread started"); + try (WatchService watcher = FileSystems.getDefault().newWatchService()) { + this.watchService = watcher; + started.countDown(); + Map> accumulatedChanges = new HashMap<>(); + while (this.running) { + try { + WatchKey key = watcher.poll(this.quietPeriod.toMillis(), TimeUnit.MILLISECONDS); + if (key == null) { + // WatchService returned without any changes + if (!accumulatedChanges.isEmpty()) { + // We have queued changes, that means there were no changes + // since the quiet period + fireCallback(accumulatedChanges); + accumulatedChanges.clear(); + } + } + else { + accumulateChanges(key, accumulatedChanges); + } + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + logger.debug("Watch thread stopped"); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private void accumulateChanges(WatchKey key, Map> accumulatedChanges) + throws IOException { + 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.affectsFile(file)) { + accumulatedChanges.computeIfAbsent(registration, (ignore) -> new ArrayList<>()) + .add(new Change(file, Type.from(event.kind()))); + } + } + } + key.reset(); + } + + private void fireCallback(Map> accumulatedChanges) { + for (Entry> entry : accumulatedChanges.entrySet()) { + Changes changes = new Changes(entry.getValue()); + if (!changes.isEmpty()) { + entry.getKey().callback().onChange(changes); + } + } + } + + private void onThreadException(Thread thread, Throwable throwable) { + logger.error("Uncaught exception in file watcher thread", throwable); + } + + private void registerWatchables(Callback callback, Set paths, WatchService watchService) throws IOException { + Set watchKeys = new HashSet<>(); + Set directories = new HashSet<>(); + Set files = new HashSet<>(); + for (Path path : paths) { + Path realPath = path.toRealPath(); + if (Files.isDirectory(realPath)) { + directories.add(realPath); + watchKeys.add(registerDirectory(realPath, watchService)); + } + else if (Files.isRegularFile(realPath)) { + files.add(realPath); + watchKeys.add(registerFile(realPath, watchService)); + } + else { + throw new IOException("'%s' is neither a file nor a directory".formatted(realPath)); + } + } + Registration registration = new Registration(callback, directories, files); + for (WatchKey watchKey : watchKeys) { + this.registrations.computeIfAbsent(watchKey, (ignore) -> new CopyOnWriteArrayList<>()).add(registration); + } + } + + private WatchKey registerFile(Path file, WatchService watchService) throws IOException { + return register(file.getParent(), watchService); + } + + private WatchKey registerDirectory(Path directory, WatchService watchService) throws IOException { + return register(directory, watchService); + } + + private WatchKey register(Path directory, WatchService watchService) throws IOException { + logger.debug(LogMessage.format("Registering '%s'", directory)); + return directory.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); + } + + @Override + public void close() { + stop(); + } + + private record Registration(Callback callback, Set directories, Set files) { + boolean affectsFile(Path file) { + return this.files.contains(file) || isInDirectories(file); + } + + private boolean isInDirectories(Path file) { + for (Path directory : this.directories) { + if (file.startsWith(directory)) { + return true; + } + } + return false; + } + } + + enum Type { + + CREATE, MODIFY, DELETE; + + private static Type from(Kind kind) { + if (kind == StandardWatchEventKinds.ENTRY_CREATE) { + return CREATE; + } + if (kind == StandardWatchEventKinds.ENTRY_DELETE) { + return DELETE; + } + if (kind == StandardWatchEventKinds.ENTRY_MODIFY) { + return MODIFY; + } + throw new IllegalArgumentException("Unknown kind: " + kind); + } + + } + + record Change(Path path, Type type) { + } + + static class Changes implements Iterable { + + private final List changes; + + Changes(List changes) { + this.changes = changes; + } + + @Override + public Iterator iterator() { + return this.changes.iterator(); + } + + boolean isEmpty() { + return this.changes.isEmpty(); + } + + } + + @FunctionalInterface + interface Callback { + + void onChange(Changes changes); + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/JksSslBundleProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/JksSslBundleProperties.java index ca89bde4a8..323c061a1e 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/JksSslBundleProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/JksSslBundleProperties.java @@ -38,6 +38,11 @@ public class JksSslBundleProperties extends SslBundleProperties { */ private final Store truststore = new Store(); + /** + * Whether to reload the SSL bundle. + */ + private boolean reloadOnUpdate; + public Store getKeystore() { return this.keystore; } @@ -46,6 +51,14 @@ public class JksSslBundleProperties extends SslBundleProperties { return this.truststore; } + public boolean isReloadOnUpdate() { + return this.reloadOnUpdate; + } + + public void setReloadOnUpdate(boolean reloadOnUpdate) { + this.reloadOnUpdate = reloadOnUpdate; + } + /** * Store properties. */ diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PemSslBundleProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PemSslBundleProperties.java index d98939350f..15ec057778 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PemSslBundleProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PemSslBundleProperties.java @@ -17,6 +17,7 @@ package org.springframework.boot.autoconfigure.ssl; import org.springframework.boot.ssl.pem.PemSslStoreBundle; +import org.springframework.boot.ssl.pem.PemSslStoreDetails; /** * {@link SslBundleProperties} for PEM-encoded certificates and private keys. @@ -39,6 +40,11 @@ public class PemSslBundleProperties extends SslBundleProperties { */ private final Store truststore = new Store(); + /** + * Whether to reload the SSL bundle. + */ + private boolean reloadOnUpdate; + /** * Whether to verify that the private key matches the public key. */ @@ -52,6 +58,14 @@ public class PemSslBundleProperties extends SslBundleProperties { return this.truststore; } + public boolean isReloadOnUpdate() { + return this.reloadOnUpdate; + } + + public void setReloadOnUpdate(boolean reloadOnUpdate) { + this.reloadOnUpdate = reloadOnUpdate; + } + public boolean isVerifyKeys() { return this.verifyKeys; } @@ -117,6 +131,10 @@ public class PemSslBundleProperties extends SslBundleProperties { this.privateKeyPassword = privateKeyPassword; } + PemSslStoreDetails asPemSslStoreDetails() { + return new PemSslStoreDetails(this.type, this.certificate, this.privateKey, this.privateKeyPassword); + } + } } 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 12b856c8a0..59fc10816c 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,28 @@ import org.springframework.context.annotation.Bean; @EnableConfigurationProperties(SslProperties.class) public class SslAutoConfiguration { - SslAutoConfiguration() { + private final SslProperties sslProperties; + + SslAutoConfiguration(SslProperties sslProperties) { + this.sslProperties = sslProperties; + } + + @Bean + FileWatcher fileWatcher() { + return new FileWatcher("ssl-bundle-watcher", + this.sslProperties.getBundle().getWatch().getFile().getQuietPeriod()); } @Bean - public SslPropertiesBundleRegistrar sslPropertiesSslBundleRegistrar(SslProperties sslProperties) { - return new SslPropertiesBundleRegistrar(sslProperties); + 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 e8b9fd1a4c..68532a9c03 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(); 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 49aced7490..a755a871b8 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 89a3e7c126..a39460f286 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,19 @@ 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.HashSet; import java.util.Map; -import java.util.function.Function; +import java.util.Map.Entry; +import java.util.Set; -import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.ssl.SslBundleRegistry; +import org.springframework.boot.ssl.pem.PemSslStoreDetails; +import org.springframework.boot.ssl.pem.PemSslStoreDetails.Type; +import org.springframework.util.ResourceUtils; /** * A {@link SslBundleRegistrar} that registers SSL bundles based @@ -28,25 +36,104 @@ import org.springframework.boot.ssl.SslBundleRegistry; * * @author Scott Frederick * @author Phillip Webb + * @author Moritz Halbritter */ class SslPropertiesBundleRegistrar implements SslBundleRegistrar { 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); + registerPemBundles(registry, this.properties.getPem()); + registerJksBundles(registry, this.properties.getJks()); + } + + private void registerJksBundles(SslBundleRegistry registry, Map bundles) { + for (Entry bundle : bundles.entrySet()) { + String bundleName = bundle.getKey(); + JksSslBundleProperties properties = bundle.getValue(); + registry.registerBundle(bundleName, PropertiesSslBundle.get(properties)); + if (properties.isReloadOnUpdate()) { + Set locations = getPathsToWatch(properties); + this.fileWatcher.watch(locations, + (changes) -> registry.updateBundle(bundleName, PropertiesSslBundle.get(properties))); + } + } + } + + private void registerPemBundles(SslBundleRegistry registry, Map bundles) { + for (Entry bundle : bundles.entrySet()) { + String bundleName = bundle.getKey(); + PemSslBundleProperties properties = bundle.getValue(); + registry.registerBundle(bundleName, PropertiesSslBundle.get(properties)); + if (properties.isReloadOnUpdate()) { + Set locations = getPathsToWatch(properties); + this.fileWatcher.watch(locations, + (changes) -> registry.updateBundle(bundleName, PropertiesSslBundle.get(properties))); + } + } + } + + private Set getPathsToWatch(JksSslBundleProperties properties) { + Set result = new HashSet<>(); + if (properties.getKeystore().getLocation() != null) { + result.add(toPath(properties.getKeystore().getLocation())); + } + if (properties.getTruststore().getLocation() != null) { + result.add(toPath(properties.getTruststore().getLocation())); + } + return result; + } + + private Set getPathsToWatch(PemSslBundleProperties properties) { + PemSslStoreDetails keystore = properties.getKeystore().asPemSslStoreDetails(); + PemSslStoreDetails truststore = properties.getTruststore().asPemSslStoreDetails(); + Set result = new HashSet<>(); + if (keystore.privateKey() != null) { + if (keystore.getPrivateKeyType() != Type.URL) { + throw new IllegalStateException("Keystore private key is not a URL and can't be watched"); + } + result.add(toPath(keystore.privateKey())); + } + if (keystore.certificate() != null) { + if (keystore.getCertificateType() != Type.URL) { + throw new IllegalStateException("Keystore certificate is not a URL and can't be watched"); + } + result.add(toPath(keystore.certificate())); + } + if (truststore.privateKey() != null) { + if (truststore.getPrivateKeyType() != Type.URL) { + throw new IllegalStateException("Truststore private key is not a URL and can't be watched"); + } + result.add(toPath(truststore.privateKey())); + } + if (truststore.certificate() != null) { + if (truststore.getCertificateType() != Type.URL) { + throw new IllegalStateException("Truststore certificate is not a URL and can't be watched"); + } + result.add(toPath(truststore.certificate())); + } + return result; } - private

void registerBundles(SslBundleRegistry registry, Map properties, - Function bundleFactory) { - properties.forEach((bundleName, bundleProperties) -> registry.registerBundle(bundleName, - bundleFactory.apply(bundleProperties))); + private Path toPath(String location) { + try { + URL url = ResourceUtils.getURL(location); + if (!"file".equals(url.getProtocol())) { + throw new IllegalStateException("Location '%s' doesn't point to a file".formatted(location)); + } + return Path.of(url.getFile()).toAbsolutePath(); + } + catch (FileNotFoundException ex) { + throw new UncheckedIOException("Failed to get URI to location '%s'".formatted(location), ex); + } } } 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 0000000000..35dcd6ff10 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/FileWatcherTests.java @@ -0,0 +1,179 @@ +/* + * 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.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Arrays; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.apache.activemq.artemis.utils.collections.ConcurrentHashSet; +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 org.springframework.boot.autoconfigure.ssl.FileWatcher.Callback; +import org.springframework.boot.autoconfigure.ssl.FileWatcher.Change; +import org.springframework.boot.autoconfigure.ssl.FileWatcher.Changes; +import org.springframework.boot.autoconfigure.ssl.FileWatcher.Type; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +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("filewatcher-test-", Duration.ofMillis(10)); + } + + @AfterEach + void tearDown() { + 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); + Set changes = callback.waitForChanges(); + assertThatHasChanges(changes, new Change(newFile, Type.CREATE)); + } + + @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); + Set changes = callback.waitForChanges(); + assertThatHasChanges(changes, new Change(deletedFile, Type.DELETE)); + } + + @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"); + Set changes = callback.waitForChanges(); + assertThatHasChanges(changes, new Change(deletedFile, Type.MODIFY)); + } + + @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"); + Set changes = callback.waitForChanges(); + assertThatHasChanges(changes, new Change(watchedFile, Type.MODIFY)); + } + + @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 shouldFailIfDirectoryOrFileDoesntExist(@TempDir Path tempDir) { + Path directory = tempDir.resolve("dir1"); + assertThatThrownBy(() -> this.fileWatcher.watch(Set.of(directory), new WaitingCallback())) + .isInstanceOf(UncheckedIOException.class) + .hasMessageMatching("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.stop(); + this.fileWatcher.stop(); + }).doesNotThrowAnyException(); + } + + private void assertThatHasChanges(Set candidates, Change... changes) { + assertThat(candidates).containsAll(Arrays.asList(changes)); + } + + private static class WaitingCallback implements Callback { + + private final CountDownLatch latch = new CountDownLatch(1); + + private final Set changes = new ConcurrentHashSet<>(); + + @Override + public void onChange(Changes changes) { + for (Change change : changes) { + this.changes.add(change); + } + this.latch.countDown(); + } + + Set waitForChanges() throws InterruptedException { + if (!this.latch.await(10, TimeUnit.SECONDS)) { + fail("Timeout while waiting for changes"); + } + return this.changes; + } + + void expectNoChanges() throws InterruptedException { + if (!this.latch.await(100, TimeUnit.MILLISECONDS)) { + return; + } + assertThat(this.changes).isEmpty(); + } + + } + +} 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 0000000000..cc17964484 --- /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("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("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("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("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/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 fa79265755..de1e898317 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,21 +16,37 @@ package org.springframework.boot.ssl; +import java.util.Collections; +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.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 * @since 3.1.0 */ public class DefaultSslBundleRegistry implements SslBundleRegistry, SslBundles { + private static final Log logger = LogFactory.getLog(DefaultSslBundleRegistry.class); + private final Map bundles = new ConcurrentHashMap<>(); + private final Map>> listeners = new ConcurrentHashMap<>(); + + private final Set bundlesWithoutListeners = ConcurrentHashMap.newKeySet(); + public DefaultSslBundleRegistry() { } @@ -48,12 +64,55 @@ public class DefaultSslBundleRegistry implements SslBundleRegistry, SslBundles { @Override public SslBundle getBundle(String name) { + return getBundle(name, null); + } + + @Override + public SslBundle getBundle(String name, Consumer onUpdate) throws NoSuchSslBundleException { Assert.notNull(name, "Name must not be null"); SslBundle bundle = this.bundles.get(name); if (bundle == null) { throw new NoSuchSslBundleException(name, "SSL bundle name '%s' cannot be found".formatted(name)); } + addListener(name, onUpdate); return bundle; } + @Override + public void updateBundle(String name, SslBundle sslBundle) { + Assert.notNull(name, "Name must not be null"); + SslBundle bundle = this.bundles.get(name); + if (bundle == null) { + throw new NoSuchSslBundleException(name, "SSL bundle name '%s' cannot be found".formatted(name)); + } + this.bundles.put(name, sslBundle); + notifyListeners(name, sslBundle); + logMissingListeners(name); + } + + private void notifyListeners(String name, SslBundle sslBundle) { + List> listeners = this.listeners.getOrDefault(name, Collections.emptyList()); + for (Consumer listener : listeners) { + listener.accept(sslBundle); + } + } + + private void addListener(String name, Consumer onUpdate) { + if (onUpdate == null) { + this.bundlesWithoutListeners.add(name); + } + else { + this.listeners.computeIfAbsent(name, (ignore) -> new CopyOnWriteArrayList<>()).add(onUpdate); + } + } + + private void logMissingListeners(String name) { + if (logger.isWarnEnabled()) { + if (this.bundlesWithoutListeners.contains(name)) { + logger.warn(LogMessage.format("SSL bundle '%s' has been updated, but not all consumers are updateable", + name)); + } + } + } + } 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 990a481066..5e1eea44bf 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,12 @@ public interface SslBundleRegistry { */ void registerBundle(String name, SslBundle bundle); + /** + * Updates an {@link SslBundle}. + * @param name the bundle name + * @param sslBundle the updated bundle + * @since 3.2.0 + */ + void updateBundle(String name, SslBundle sslBundle); + } 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 ed8a0ea9cd..4dc0a11ff7 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,10 +16,13 @@ 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 { @@ -32,4 +35,15 @@ public interface SslBundles { */ SslBundle getBundle(String bundleName) throws NoSuchSslBundleException; + /** + * Return an {@link SslBundle} with the provided name. + * @param bundleName the bundle name + * @param onUpdate the callback, which is called when the bundle is updated or + * {@code null} + * @return the bundle + * @throws NoSuchSslBundleException if a bundle with the provided name does not exist + * @since 3.2.0 + */ + SslBundle getBundle(String bundleName, Consumer onUpdate) throws NoSuchSslBundleException; + } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java deleted file mode 100644 index 3178285750..0000000000 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * 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.ssl.pem; - -import java.io.IOException; -import java.io.InputStreamReader; -import java.io.Reader; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.util.regex.Pattern; - -import org.springframework.util.FileCopyUtils; -import org.springframework.util.ResourceUtils; - -/** - * Utility to load PEM content. - * - * @author Scott Frederick - * @author Phillip Webb - */ -final class PemContent { - - private static final Pattern PEM_HEADER = Pattern.compile("-+BEGIN\\s+[^-]*-+", Pattern.CASE_INSENSITIVE); - - private static final Pattern PEM_FOOTER = Pattern.compile("-+END\\s+[^-]*-+", Pattern.CASE_INSENSITIVE); - - private PemContent() { - } - - static String load(String content) { - if (content == null || isPemContent(content)) { - return content; - } - try { - URL url = ResourceUtils.getURL(content); - try (Reader reader = new InputStreamReader(url.openStream(), StandardCharsets.UTF_8)) { - return FileCopyUtils.copyToString(reader); - } - } - catch (IOException ex) { - throw new IllegalStateException( - "Error reading certificate or key from file '" + content + "':" + ex.getMessage(), ex); - } - } - - private static boolean isPemContent(String content) { - return content != null && PEM_HEADER.matcher(content).find() && PEM_FOOTER.matcher(content).find(); - } - -} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreBundle.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreBundle.java index b71750f361..93dbe0b330 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreBundle.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreBundle.java @@ -17,6 +17,10 @@ package org.springframework.boot.ssl.pem; import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.net.URL; +import java.nio.charset.StandardCharsets; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; @@ -26,7 +30,10 @@ import java.security.cert.X509Certificate; import org.springframework.boot.ssl.SslStoreBundle; import org.springframework.boot.ssl.pem.KeyVerifier.Result; +import org.springframework.boot.ssl.pem.PemSslStoreDetails.Type; import org.springframework.util.Assert; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.ResourceUtils; import org.springframework.util.StringUtils; /** @@ -149,12 +156,14 @@ public class PemSslStoreBundle implements SslStoreBundle { } private static PrivateKey loadPrivateKey(PemSslStoreDetails details) { - String privateKeyContent = PemContent.load(details.privateKey()); + String privateKeyContent = (details.getPrivateKeyType() == Type.PEM) ? details.privateKey() + : load(details.privateKey()); return PemPrivateKeyParser.parse(privateKeyContent, details.privateKeyPassword()); } private static X509Certificate[] loadCertificates(PemSslStoreDetails details) { - String certificateContent = PemContent.load(details.certificate()); + String certificateContent = (details.getCertificateType() == Type.PEM) ? details.certificate() + : load(details.certificate()); X509Certificate[] certificates = PemCertificateParser.parse(certificateContent); Assert.state(certificates != null && certificates.length > 0, "Loaded certificates are empty"); return certificates; @@ -180,4 +189,20 @@ public class PemSslStoreBundle implements SslStoreBundle { } } + private static String load(String location) { + if (location == null) { + return null; + } + try { + URL url = ResourceUtils.getURL(location); + try (Reader reader = new InputStreamReader(url.openStream(), StandardCharsets.UTF_8)) { + return FileCopyUtils.copyToString(reader); + } + } + catch (IOException ex) { + throw new IllegalStateException( + "Error reading certificate or key from file '" + location + "':" + ex.getMessage(), ex); + } + } + } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreDetails.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreDetails.java index 81d68eb695..7e9376b7df 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreDetails.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreDetails.java @@ -17,6 +17,7 @@ package org.springframework.boot.ssl.pem; import java.security.KeyStore; +import java.util.regex.Pattern; import org.springframework.util.ResourceUtils; import org.springframework.util.StringUtils; @@ -37,6 +38,10 @@ import org.springframework.util.StringUtils; */ public record PemSslStoreDetails(String type, String certificate, String privateKey, String privateKeyPassword) { + private static final Pattern PEM_HEADER = Pattern.compile("-+BEGIN\\s+[^-]*-+", Pattern.CASE_INSENSITIVE); + + private static final Pattern PEM_FOOTER = Pattern.compile("-+END\\s+[^-]*-+", Pattern.CASE_INSENSITIVE); + public PemSslStoreDetails(String type, String certificate, String privateKey) { this(type, certificate, privateKey, null); } @@ -59,6 +64,24 @@ public record PemSslStoreDetails(String type, String certificate, String private return new PemSslStoreDetails(this.type, this.certificate, this.privateKey, password); } + /** + * Returns the type of the private key. + * @return the type of the private key + * @since 3.2.0 + */ + public Type getPrivateKeyType() { + return (isPemContent(this.privateKey)) ? Type.PEM : Type.URL; + } + + /** + * Returns the type of the certificate. + * @return the type of the certificate + * @since 3.2.0 + */ + public Type getCertificateType() { + return (isPemContent(this.certificate)) ? Type.PEM : Type.URL; + } + boolean isEmpty() { return isEmpty(this.type) && isEmpty(this.certificate) && isEmpty(this.privateKey); } @@ -77,4 +100,24 @@ public record PemSslStoreDetails(String type, String certificate, String private return new PemSslStoreDetails(null, certificate, null); } + private static boolean isPemContent(String content) { + return content != null && PEM_HEADER.matcher(content).find() && PEM_FOOTER.matcher(content).find(); + } + + /** + * Type of key or certificate. + */ + public enum Type { + + /** + * URL loadable by {@link ResourceUtils#getURL}. + */ + URL, + /** + * PEM content. + */ + PEM + + } + } 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 306077ffbd..cb2e8c5fe7 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 @@ -25,9 +25,12 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import reactor.netty.http.HttpProtocol; import reactor.netty.http.server.HttpServer; +import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactory; import org.springframework.boot.web.reactive.server.ReactiveWebServerFactory; import org.springframework.boot.web.server.Shutdown; @@ -42,10 +45,13 @@ import org.springframework.util.Assert; * {@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 { + private static final Log logger = LogFactory.getLog(NettyReactiveWebServerFactory.class); + private Set serverCustomizers = new LinkedHashSet<>(); private final List routeProviders = new ArrayList<>(); @@ -170,7 +176,14 @@ public class NettyReactiveWebServerFactory extends AbstractReactiveWebServerFact } private HttpServer customizeSslConfiguration(HttpServer httpServer) { - return new SslServerCustomizer(getHttp2(), getSsl().getClientAuth(), getSslBundle()).apply(httpServer); + SslServerCustomizer sslServerCustomizer = new SslServerCustomizer(getHttp2(), getSsl().getClientAuth()); + SslBundle sslBundle = getSslBundle((updatedBundle) -> { + logger.debug("SSL Bundle has been updated, reloading SSL configuration"); + sslServerCustomizer.setSslBundle(updatedBundle); + sslServerCustomizer.reload(); + }); + sslServerCustomizer.setSslBundle(sslBundle); + return sslServerCustomizer.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 208ce2a2e7..01d1229e10 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 5480c4d0c8..146077c7c5 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 @@ -21,11 +21,13 @@ 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 org.springframework.boot.ssl.SslBundle; import org.springframework.boot.ssl.SslOptions; import org.springframework.boot.web.server.Http2; import org.springframework.boot.web.server.Ssl; +import org.springframework.util.Assert; /** * {@link NettyServerCustomizer} that configures SSL for the given Reactor Netty server @@ -36,6 +38,7 @@ import org.springframework.boot.web.server.Ssl; * @author Chris Bono * @author Cyril Dangerville * @author Scott Frederick + * @author Moritz Halbritter * @since 2.0.0 */ public class SslServerCustomizer implements NettyServerCustomizer { @@ -44,7 +47,13 @@ public class SslServerCustomizer implements NettyServerCustomizer { private final Ssl.ClientAuth clientAuth; - private final SslBundle sslBundle; + private volatile SslBundle sslBundle; + + private volatile SslProvider currentSslProvider; + + SslServerCustomizer(Http2 http2, Ssl.ClientAuth clientAuth) { + this(http2, clientAuth, null); + } public SslServerCustomizer(Http2 http2, Ssl.ClientAuth clientAuth, SslBundle sslBundle) { this.http2 = http2; @@ -52,13 +61,25 @@ public class SslServerCustomizer implements NettyServerCustomizer { this.sslBundle = sslBundle; } + void setSslBundle(SslBundle sslBundle) { + this.sslBundle = sslBundle; + } + @Override public HttpServer apply(HttpServer server) { AbstractProtocolSslContextSpec sslContextSpec = createSslContextSpec(); - return server.secure((spec) -> spec.sslContext(sslContextSpec)); + this.currentSslProvider = SslProvider.builder().sslContext(sslContextSpec).build(); + return server.secure((spec) -> spec.sslContext(sslContextSpec) + .setSniAsyncMappings((domainName, promise) -> promise.setSuccess(this.currentSslProvider))); + } + + void reload() { + AbstractProtocolSslContextSpec sslContextSpec = createSslContextSpec(); + this.currentSslProvider = SslProvider.builder().sslContext(sslContextSpec).build(); } protected AbstractProtocolSslContextSpec createSslContextSpec() { + Assert.notNull(this.sslBundle, "sslBundle must not be null"); AbstractProtocolSslContextSpec sslContextSpec = (this.http2 != null && this.http2.isEnabled()) ? Http2SslContextSpec.forServer(this.sslBundle.getManagers().getKeyManagerFactory()) : Http11SslContextSpec.forServer(this.sslBundle.getManagers().getKeyManagerFactory()); 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 516c61db00..904e519945 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 @@ -39,6 +39,7 @@ import org.springframework.util.StringUtils; * @author Andy Wilkinson * @author Scott Frederick * @author Cyril Dangerville + * @author Moritz Halbritter */ class SslConnectorCustomizer implements TomcatConnectorCustomizer { @@ -66,15 +67,19 @@ class SslConnectorCustomizer implements TomcatConnectorCustomizer { * @param protocol the protocol */ void configureSsl(AbstractHttp11JsseProtocol protocol) { - SslBundleKey key = this.sslBundle.getKey(); - SslStoreBundle stores = this.sslBundle.getStores(); - SslOptions options = this.sslBundle.getOptions(); protocol.setSSLEnabled(true); SSLHostConfig sslHostConfig = new SSLHostConfig(); sslHostConfig.setHostName(protocol.getDefaultSSLHostConfigName()); - sslHostConfig.setSslProtocol(this.sslBundle.getProtocol()); - protocol.addSslHostConfig(sslHostConfig); configureSslClientAuth(sslHostConfig); + applySslBundle(protocol, sslHostConfig); + protocol.addSslHostConfig(sslHostConfig, true); + } + + private void applySslBundle(AbstractHttp11JsseProtocol protocol, SSLHostConfig sslHostConfig) { + SslBundleKey key = this.sslBundle.getKey(); + SslStoreBundle stores = this.sslBundle.getStores(); + SslOptions options = this.sslBundle.getOptions(); + sslHostConfig.setSslProtocol(this.sslBundle.getProtocol()); SSLHostConfigCertificate certificate = new SSLHostConfigCertificate(sslHostConfig, Type.UNDEFINED); String keystorePassword = (stores.getKeyStorePassword() != null) ? stores.getKeyStorePassword() : ""; certificate.setCertificateKeystorePassword(keystorePassword); @@ -89,17 +94,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 +109,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 ab31ecce73..82e43e5dba 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,12 +35,15 @@ 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; import org.apache.tomcat.util.modeler.Registry; import org.apache.tomcat.util.scan.StandardJarScanFilter; +import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.util.LambdaSafe; import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactory; import org.springframework.boot.web.reactive.server.ReactiveWebServerFactory; @@ -57,11 +60,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 +230,15 @@ public class TomcatReactiveWebServerFactory extends AbstractReactiveWebServerFac } private void customizeSsl(Connector connector) { - new SslConnectorCustomizer(getSsl().getClientAuth(), getSslBundle()).customize(connector); + SslBundle sslBundle = getSslBundle((updatedBundle) -> { + logger.debug("SSL Bundle has been updated, reloading SSL configuration"); + customizeSsl(connector, updatedBundle); + }); + customizeSsl(connector, sslBundle); + } + + private void customizeSsl(Connector connector, SslBundle sslBundle) { + new SslConnectorCustomizer(getSsl().getClientAuth(), sslBundle).customize(connector); } @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 7f05d87a11..425bfe7e22 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; @@ -69,6 +71,7 @@ import org.apache.tomcat.util.http.Rfc6265CookieProcessor; import org.apache.tomcat.util.modeler.Registry; import org.apache.tomcat.util.scan.StandardJarScanFilter; +import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.util.LambdaSafe; import org.springframework.boot.web.server.Cookie.SameSite; import org.springframework.boot.web.server.ErrorPage; @@ -103,6 +106,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 +115,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 +372,15 @@ public class TomcatServletWebServerFactory extends AbstractServletWebServerFacto } private void customizeSsl(Connector connector) { - new SslConnectorCustomizer(getSsl().getClientAuth(), getSslBundle()).customize(connector); + SslBundle sslBundle = getSslBundle((updatedBundle) -> { + logger.debug("SSL Bundle has been updated, reloading SSL configuration"); + customizeSsl(connector, updatedBundle); + }); + customizeSsl(connector, sslBundle); + } + + private void customizeSsl(Connector connector, SslBundle sslBundle) { + new SslConnectorCustomizer(getSsl().getClientAuth(), sslBundle).customize(connector); } /** 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 4a23afd753..fc1dde9afb 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 @@ -23,6 +23,7 @@ import java.nio.file.Files; import java.util.Arrays; import java.util.LinkedHashSet; import java.util.Set; +import java.util.function.Consumer; import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.ssl.SslBundles; @@ -216,6 +217,17 @@ public abstract class AbstractConfigurableWebServerFactory implements Configurab return WebServerSslBundle.get(this.ssl, this.sslBundles, this.sslStoreProvider); } + /** + * Return the {@link SslBundle} that should be used with this server, registering a + * callback for bundle updates. + * @param onUpdate the callback for bundle updates + * @return the SSL bundle + */ + @SuppressWarnings("removal") + protected final SslBundle getSslBundle(Consumer onUpdate) { + return WebServerSslBundle.get(this.ssl, this.sslBundles, this.sslStoreProvider, onUpdate); + } + /** * Return the absolute temp dir for given web server. * @param prefix server name diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServerSslBundle.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServerSslBundle.java index e722830aa4..d7a3732c9d 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServerSslBundle.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServerSslBundle.java @@ -17,6 +17,7 @@ package org.springframework.boot.web.server; import java.security.KeyStore; +import java.util.function.Consumer; import org.springframework.boot.ssl.NoSuchSslBundleException; import org.springframework.boot.ssl.SslBundle; @@ -137,6 +138,25 @@ public final class WebServerSslBundle implements SslBundle { @Deprecated(since = "3.1.0", forRemoval = true) @SuppressWarnings("removal") public static SslBundle get(Ssl ssl, SslBundles sslBundles, SslStoreProvider sslStoreProvider) { + return get(ssl, sslBundles, sslStoreProvider, null); + } + + /** + * Get the {@link SslBundle} that should be used for the given {@link Ssl} and + * {@link SslStoreProvider} instances. + * @param ssl the source {@link Ssl} instance + * @param sslBundles the bundles that should be used when {@link Ssl#getBundle()} is + * set + * @param sslStoreProvider the {@link SslStoreProvider} to use or {@code null} + * @param onUpdate the callback, which is called when the {@link SslBundle} is updated + * @return a {@link SslBundle} instance + * @throws NoSuchSslBundleException if a bundle lookup fails + * @deprecated since 3.1.0 for removal in 3.3.0 along with {@link SslStoreProvider} + */ + @Deprecated(since = "3.1.0", forRemoval = true) + @SuppressWarnings("removal") + public static SslBundle get(Ssl ssl, SslBundles sslBundles, SslStoreProvider sslStoreProvider, + Consumer onUpdate) { Assert.state(Ssl.isEnabled(ssl), "SSL is not enabled"); String keyPassword = (sslStoreProvider != null) ? sslStoreProvider.getKeyPassword() : null; keyPassword = (keyPassword != null) ? keyPassword : ssl.getKeyPassword(); @@ -149,7 +169,7 @@ public final class WebServerSslBundle implements SslBundle { Assert.state(sslBundles != null, () -> "SSL bundle '%s' was requested but no SslBundles instance was provided" .formatted(bundleName)); - return sslBundles.getBundle(bundleName); + return sslBundles.getBundle(bundleName, onUpdate); } SslStoreBundle stores = createStoreBundle(ssl); return new WebServerSslBundle(stores, keyPassword, ssl); 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 d8cf034eef..db6c48b60b 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,28 @@ class DefaultSslBundleRegistryTests { assertThat(this.registry.getBundle("test2")).isSameAs(this.bundle2); } + @Test + void updateBundleShouldNotifyListeners() { + AtomicReference updatedBundle = new AtomicReference<>(); + this.registry.registerBundle("test1", this.bundle1); + this.registry.getBundle("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"); + } + } 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 cdb30bbc50..84c0f408cd 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/ssl/pem/PemContentTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreDetailsTests.java similarity index 65% rename from spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemContentTests.java rename to spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreDetailsTests.java index 649d66f699..6b37bb038a 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemContentTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreDetailsTests.java @@ -16,29 +16,21 @@ package org.springframework.boot.ssl.pem; -import java.io.IOException; -import java.nio.charset.StandardCharsets; - import org.junit.jupiter.api.Test; -import org.springframework.core.io.ClassPathResource; +import org.springframework.boot.ssl.pem.PemSslStoreDetails.Type; import static org.assertj.core.api.Assertions.assertThat; /** - * Tests for {@link PemContent}. + * Tests for {@link PemSslStoreDetails}. * - * @author Phillip Webb + * @author Moritz Halbritter */ -class PemContentTests { - - @Test - void loadWhenContentIsNullReturnsNull() { - assertThat(PemContent.load(null)).isNull(); - } +class PemSslStoreDetailsTests { @Test - void loadWhenContentIsPemContentReturnsContent() { + void pemContent() { String content = """ -----BEGIN CERTIFICATE----- MIICpDCCAYwCCQCDOqHKPjAhCTANBgkqhkiG9w0BAQUFADAUMRIwEAYDVQQDDAls @@ -57,21 +49,24 @@ class PemContentTests { +lGuHKdhNOVW9CmqPD1y76o6c8PQKuF7KZEoY2jvy3GeIfddBvqXgZ4PbWvFz1jO 32C9XWHwRA4= -----END CERTIFICATE-----"""; - assertThat(PemContent.load(content)).isEqualTo(content); + PemSslStoreDetails details = new PemSslStoreDetails("JKS", content, content); + assertThat(details.getCertificateType()).isEqualTo(Type.PEM); + assertThat(details.getPrivateKeyType()).isEqualTo(Type.PEM); } @Test - void loadWhenClasspathLocationReturnsContent() throws IOException { - String actual = PemContent.load("classpath:test-cert.pem"); - String expected = new ClassPathResource("test-cert.pem").getContentAsString(StandardCharsets.UTF_8); - assertThat(actual).isEqualTo(expected); + void location() { + PemSslStoreDetails details = new PemSslStoreDetails("JKS", "classpath:certificate.pem", "file:privatekey.pem"); + assertThat(details.getCertificateType()).isEqualTo(Type.URL); + assertThat(details.getPrivateKeyType()).isEqualTo(Type.URL); } @Test - void loadWhenFileLocationReturnsContent() throws IOException { - String actual = PemContent.load("src/test/resources/test-cert.pem"); - String expected = new ClassPathResource("test-cert.pem").getContentAsString(StandardCharsets.UTF_8); - assertThat(actual).isEqualTo(expected); + void empty() { + PemSslStoreDetails details = new PemSslStoreDetails(null, null, null); + assertThat(details.getCertificateType()).isEqualTo(Type.URL); + assertThat(details.getPrivateKeyType()).isEqualTo(Type.URL); + assertThat(details.isEmpty()).isTrue(); } } 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 652042688c..3ef161b301 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/TomcatServletWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactoryTests.java index e619c9120c..5dafd69bc4 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 3e5a79f00b..d7236bee10 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 0000000000..dd4be7410d --- /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 0000000000..712fa35133 --- /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 0000000000..7c13395e0a --- /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 0000000000..9917897564 --- /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 0000000000..dd4be7410d --- /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 0000000000..712fa35133 --- /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 0000000000..7c13395e0a --- /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 0000000000..9917897564 --- /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-----