Implement SSL hot reload for Netty and Tomcat

Closes gh-37808
This commit is contained in:
Andy Wilkinson 2023-10-16 14:07:39 +01:00
parent 6f5688ad3e
commit 19fd88b25b
33 changed files with 1285 additions and 104 deletions

View File

@ -0,0 +1,229 @@
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.autoconfigure.ssl;
import java.io.Closeable;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.ClosedWatchServiceException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardWatchEventKinds;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.time.Duration;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.core.log.LogMessage;
import org.springframework.util.Assert;
/**
* Watches files and directories and triggers a callback on change.
*
* @author Moritz Halbritter
* @author Phillip Webb
*/
class FileWatcher implements Closeable {
private static final Log logger = LogFactory.getLog(FileWatcher.class);
private final Duration quietPeriod;
private final Object lock = new Object();
private WatcherThread thread;
/**
* Create a new {@link FileWatcher} instance.
* @param quietPeriod the duration that no file changes should occur before triggering
* actions
*/
FileWatcher(Duration quietPeriod) {
Assert.notNull(quietPeriod, "QuietPeriod must not be null");
this.quietPeriod = quietPeriod;
}
/**
* Watch the given files or directories for changes.
* @param paths the files or directories to watch
* @param action the action to take when changes are detected
*/
void watch(Set<Path> paths, Runnable action) {
Assert.notNull(paths, "Paths must not be null");
Assert.notNull(action, "Action must not be null");
if (paths.isEmpty()) {
return;
}
synchronized (this.lock) {
try {
if (this.thread == null) {
this.thread = new WatcherThread();
this.thread.start();
}
this.thread.register(new Registration(paths, action));
}
catch (IOException ex) {
throw new UncheckedIOException("Failed to register paths for watching: " + paths, ex);
}
}
}
@Override
public void close() throws IOException {
synchronized (this.lock) {
if (this.thread != null) {
this.thread.close();
this.thread.interrupt();
try {
this.thread.join();
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
this.thread = null;
}
}
}
/**
* The watcher thread used to check for changes.
*/
private class WatcherThread extends Thread implements Closeable {
private final WatchService watchService = FileSystems.getDefault().newWatchService();
private final Map<WatchKey, List<Registration>> registrations = new ConcurrentHashMap<>();
private volatile boolean running = true;
WatcherThread() throws IOException {
setName("ssl-bundle-watcher");
setDaemon(true);
setUncaughtExceptionHandler(this::onThreadException);
}
private void onThreadException(Thread thread, Throwable throwable) {
logger.error("Uncaught exception in file watcher thread", throwable);
}
void register(Registration registration) throws IOException {
for (Path path : registration.paths()) {
if (!Files.isRegularFile(path) && !Files.isDirectory(path)) {
throw new IOException("'%s' is neither a file nor a directory".formatted(path));
}
Path directory = Files.isDirectory(path) ? path : path.getParent();
WatchKey watchKey = register(directory);
this.registrations.computeIfAbsent(watchKey, (key) -> new CopyOnWriteArrayList<>()).add(registration);
}
}
private WatchKey register(Path directory) throws IOException {
logger.debug(LogMessage.format("Registering '%s'", directory));
return directory.register(this.watchService, StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE);
}
@Override
public void run() {
logger.debug("Watch thread started");
Set<Runnable> actions = new HashSet<>();
while (this.running) {
try {
long timeout = FileWatcher.this.quietPeriod.toMillis();
WatchKey key = this.watchService.poll(timeout, TimeUnit.MILLISECONDS);
if (key == null) {
actions.forEach(this::runSafely);
actions.clear();
}
else {
accumulate(key, actions);
key.reset();
}
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
catch (ClosedWatchServiceException ex) {
logger.debug("File watcher has been closed");
this.running = false;
}
}
logger.debug("Watch thread stopped");
}
private void runSafely(Runnable action) {
try {
action.run();
}
catch (Throwable ex) {
logger.error("Unexpected SSL reload error", ex);
}
}
private void accumulate(WatchKey key, Set<Runnable> actions) {
List<Registration> registrations = this.registrations.get(key);
Path directory = (Path) key.watchable();
for (WatchEvent<?> event : key.pollEvents()) {
Path file = directory.resolve((Path) event.context());
for (Registration registration : registrations) {
if (registration.manages(file)) {
actions.add(registration.action());
}
}
}
}
@Override
public void close() throws IOException {
this.running = false;
this.watchService.close();
}
}
/**
* An individual watch registration.
*/
private record Registration(Set<Path> paths, Runnable action) {
Registration {
paths = paths.stream().map(Path::toAbsolutePath).collect(Collectors.toSet());
}
boolean manages(Path file) {
Path absolutePath = file.toAbsolutePath();
return this.paths.contains(absolutePath) || isInDirectories(absolutePath);
}
private boolean isInDirectories(Path file) {
return this.paths.stream().filter(Files::isDirectory).anyMatch(file::startsWith);
}
}
}

View File

@ -16,8 +16,7 @@
package org.springframework.boot.autoconfigure.ssl;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@ -37,19 +36,27 @@ import org.springframework.context.annotation.Bean;
@EnableConfigurationProperties(SslProperties.class)
public class SslAutoConfiguration {
SslAutoConfiguration() {
private final SslProperties sslProperties;
SslAutoConfiguration(SslProperties sslProperties) {
this.sslProperties = sslProperties;
}
@Bean
public SslPropertiesBundleRegistrar sslPropertiesSslBundleRegistrar(SslProperties sslProperties) {
return new SslPropertiesBundleRegistrar(sslProperties);
FileWatcher fileWatcher() {
return new FileWatcher(this.sslProperties.getBundle().getWatch().getFile().getQuietPeriod());
}
@Bean
SslPropertiesBundleRegistrar sslPropertiesSslBundleRegistrar(FileWatcher fileWatcher) {
return new SslPropertiesBundleRegistrar(this.sslProperties, fileWatcher);
}
@Bean
@ConditionalOnMissingBean({ SslBundleRegistry.class, SslBundles.class })
public DefaultSslBundleRegistry sslBundleRegistry(List<SslBundleRegistrar> sslBundleRegistrars) {
DefaultSslBundleRegistry sslBundleRegistry(ObjectProvider<SslBundleRegistrar> sslBundleRegistrars) {
DefaultSslBundleRegistry registry = new DefaultSslBundleRegistry();
sslBundleRegistrars.forEach((registrar) -> registrar.registerBundles(registry));
sslBundleRegistrars.orderedStream().forEach((registrar) -> registrar.registerBundles(registry));
return registry;
}

View File

@ -36,7 +36,7 @@ public abstract class SslBundleProperties {
private final Key key = new Key();
/**
* Options for the SLL connection.
* Options for the SSL connection.
*/
private final Options options = new Options();
@ -45,6 +45,11 @@ public abstract class SslBundleProperties {
*/
private String protocol = SslBundle.DEFAULT_PROTOCOL;
/**
* Whether to reload the SSL bundle.
*/
private boolean reloadOnUpdate;
public Key getKey() {
return this.key;
}
@ -61,6 +66,14 @@ public abstract class SslBundleProperties {
this.protocol = protocol;
}
public boolean isReloadOnUpdate() {
return this.reloadOnUpdate;
}
public void setReloadOnUpdate(boolean reloadOnUpdate) {
this.reloadOnUpdate = reloadOnUpdate;
}
public static class Options {
/**

View File

@ -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<String, JksSslBundleProperties> jks = new LinkedHashMap<>();
/**
* Trust material watching.
*/
private final Watch watch = new Watch();
public Map<String, PemSslBundleProperties> 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;
}
}
}
}
}

View File

@ -16,11 +16,22 @@
package org.springframework.boot.autoconfigure.ssl;
import java.io.FileNotFoundException;
import java.io.UncheckedIOException;
import java.net.URL;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslBundleRegistry;
import org.springframework.util.Assert;
import org.springframework.util.ResourceUtils;
import org.springframework.util.StringUtils;
/**
* A {@link SslBundleRegistrar} that registers SSL bundles based
@ -28,25 +39,87 @@ import org.springframework.boot.ssl.SslBundleRegistry;
*
* @author Scott Frederick
* @author Phillip Webb
* @author Moritz Halbritter
*/
class SslPropertiesBundleRegistrar implements SslBundleRegistrar {
private static final Pattern PEM_CONTENT = Pattern.compile("-+BEGIN\\s+[^-]*-+", Pattern.CASE_INSENSITIVE);
private final SslProperties.Bundles properties;
SslPropertiesBundleRegistrar(SslProperties properties) {
private final FileWatcher fileWatcher;
SslPropertiesBundleRegistrar(SslProperties properties, FileWatcher fileWatcher) {
this.properties = properties.getBundle();
this.fileWatcher = fileWatcher;
}
@Override
public void registerBundles(SslBundleRegistry registry) {
registerBundles(registry, this.properties.getPem(), PropertiesSslBundle::get);
registerBundles(registry, this.properties.getJks(), PropertiesSslBundle::get);
registerBundles(registry, this.properties.getPem(), PropertiesSslBundle::get, this::getLocations);
registerBundles(registry, this.properties.getJks(), PropertiesSslBundle::get, this::getLocations);
}
private <P extends SslBundleProperties> void registerBundles(SslBundleRegistry registry, Map<String, P> properties,
Function<P, SslBundle> bundleFactory) {
properties.forEach((bundleName, bundleProperties) -> registry.registerBundle(bundleName,
bundleFactory.apply(bundleProperties)));
Function<P, SslBundle> bundleFactory, Function<P, Set<Location>> locationsSupplier) {
properties.forEach((bundleName, bundleProperties) -> {
SslBundle bundle = bundleFactory.apply(bundleProperties);
registry.registerBundle(bundleName, bundle);
if (bundleProperties.isReloadOnUpdate()) {
Set<Path> paths = locationsSupplier.apply(bundleProperties)
.stream()
.filter(Location::hasValue)
.map((location) -> toPath(bundleName, location))
.collect(Collectors.toSet());
this.fileWatcher.watch(paths,
() -> registry.updateBundle(bundleName, bundleFactory.apply(bundleProperties)));
}
});
}
private Set<Location> getLocations(JksSslBundleProperties properties) {
JksSslBundleProperties.Store keystore = properties.getKeystore();
JksSslBundleProperties.Store truststore = properties.getTruststore();
Set<Location> locations = new LinkedHashSet<>();
locations.add(new Location("keystore.location", keystore.getLocation()));
locations.add(new Location("truststore.location", truststore.getLocation()));
return locations;
}
private Set<Location> getLocations(PemSslBundleProperties properties) {
PemSslBundleProperties.Store keystore = properties.getKeystore();
PemSslBundleProperties.Store truststore = properties.getTruststore();
Set<Location> locations = new LinkedHashSet<>();
locations.add(new Location("keystore.private-key", keystore.getPrivateKey()));
locations.add(new Location("keystore.certificate", keystore.getCertificate()));
locations.add(new Location("truststore.private-key", truststore.getPrivateKey()));
locations.add(new Location("truststore.certificate", truststore.getCertificate()));
return locations;
}
private Path toPath(String bundleName, Location watchableLocation) {
String value = watchableLocation.value();
String field = watchableLocation.field();
Assert.state(!PEM_CONTENT.matcher(value).find(),
() -> "SSL bundle '%s' '%s' is not a URL and can't be watched".formatted(bundleName, field));
try {
URL url = ResourceUtils.getURL(value);
Assert.state("file".equalsIgnoreCase(url.getProtocol()),
() -> "SSL bundle '%s' '%s' URL '%s' doesn't point to a file".formatted(bundleName, field, url));
return Path.of(url.getFile()).toAbsolutePath();
}
catch (FileNotFoundException ex) {
throw new UncheckedIOException(
"SSL bundle '%s' '%s' location '%s' cannot be watched".formatted(bundleName, field, value), ex);
}
}
private record Location(String field, String value) {
boolean hasValue() {
return StringUtils.hasText(this.value);
}
}
}

View File

@ -0,0 +1,200 @@
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.autoconfigure.ssl;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.fail;
/**
* Tests for {@link FileWatcher}.
*
* @author Moritz Halbritter
*/
class FileWatcherTests {
private FileWatcher fileWatcher;
@BeforeEach
void setUp() {
this.fileWatcher = new FileWatcher(Duration.ofMillis(10));
}
@AfterEach
void tearDown() throws IOException {
this.fileWatcher.close();
}
@Test
void shouldTriggerOnFileCreation(@TempDir Path tempDir) throws Exception {
Path newFile = tempDir.resolve("new-file.txt");
WaitingCallback callback = new WaitingCallback();
this.fileWatcher.watch(Set.of(tempDir), callback);
Files.createFile(newFile);
callback.expectChanges();
}
@Test
void shouldTriggerOnFileDeletion(@TempDir Path tempDir) throws Exception {
Path deletedFile = tempDir.resolve("deleted-file.txt");
Files.createFile(deletedFile);
WaitingCallback callback = new WaitingCallback();
this.fileWatcher.watch(Set.of(tempDir), callback);
Files.delete(deletedFile);
callback.expectChanges();
}
@Test
void shouldTriggerOnFileModification(@TempDir Path tempDir) throws Exception {
Path deletedFile = tempDir.resolve("modified-file.txt");
Files.createFile(deletedFile);
WaitingCallback callback = new WaitingCallback();
this.fileWatcher.watch(Set.of(tempDir), callback);
Files.writeString(deletedFile, "Some content");
callback.expectChanges();
}
@Test
void shouldWatchFile(@TempDir Path tempDir) throws Exception {
Path watchedFile = tempDir.resolve("watched.txt");
Files.createFile(watchedFile);
WaitingCallback callback = new WaitingCallback();
this.fileWatcher.watch(Set.of(watchedFile), callback);
Files.writeString(watchedFile, "Some content");
callback.expectChanges();
}
@Test
void shouldIgnoreNotWatchedFiles(@TempDir Path tempDir) throws Exception {
Path watchedFile = tempDir.resolve("watched.txt");
Path notWatchedFile = tempDir.resolve("not-watched.txt");
Files.createFile(watchedFile);
Files.createFile(notWatchedFile);
WaitingCallback callback = new WaitingCallback();
this.fileWatcher.watch(Set.of(watchedFile), callback);
Files.writeString(notWatchedFile, "Some content");
callback.expectNoChanges();
}
@Test
void shouldFailIfDirectoryOrFileDoesNotExist(@TempDir Path tempDir) {
Path directory = tempDir.resolve("dir1");
assertThatExceptionOfType(UncheckedIOException.class)
.isThrownBy(() -> this.fileWatcher.watch(Set.of(directory), new WaitingCallback()))
.withMessageMatching("Failed to register paths for watching: \\[.+/dir1]");
}
@Test
void shouldNotFailIfDirectoryIsRegisteredMultipleTimes(@TempDir Path tempDir) {
WaitingCallback callback = new WaitingCallback();
assertThatCode(() -> {
this.fileWatcher.watch(Set.of(tempDir), callback);
this.fileWatcher.watch(Set.of(tempDir), callback);
}).doesNotThrowAnyException();
}
@Test
void shouldNotFailIfStoppedMultipleTimes(@TempDir Path tempDir) {
WaitingCallback callback = new WaitingCallback();
this.fileWatcher.watch(Set.of(tempDir), callback);
assertThatCode(() -> {
this.fileWatcher.close();
this.fileWatcher.close();
}).doesNotThrowAnyException();
}
@Test
void testRelativeFiles() throws Exception {
Path watchedFile = Path.of(UUID.randomUUID() + ".txt");
Files.createFile(watchedFile);
try {
WaitingCallback callback = new WaitingCallback();
this.fileWatcher.watch(Set.of(watchedFile), callback);
Files.delete(watchedFile);
callback.expectChanges();
}
finally {
Files.deleteIfExists(watchedFile);
}
}
@Test
void testRelativeDirectories() throws Exception {
Path watchedDirectory = Path.of(UUID.randomUUID() + "/");
Path file = watchedDirectory.resolve("file.txt");
Files.createDirectory(watchedDirectory);
try {
WaitingCallback callback = new WaitingCallback();
this.fileWatcher.watch(Set.of(watchedDirectory), callback);
Files.createFile(file);
callback.expectChanges();
}
finally {
Files.deleteIfExists(file);
Files.deleteIfExists(watchedDirectory);
}
}
private static class WaitingCallback implements Runnable {
private final CountDownLatch latch = new CountDownLatch(1);
volatile boolean changed = false;
@Override
public void run() {
this.changed = true;
this.latch.countDown();
}
void expectChanges() throws InterruptedException {
waitForChanges(true);
assertThat(this.changed).as("changed").isTrue();
}
void expectNoChanges() throws InterruptedException {
waitForChanges(false);
assertThat(this.changed).as("changed").isFalse();
}
void waitForChanges(boolean fail) throws InterruptedException {
if (!this.latch.await(5, TimeUnit.SECONDS)) {
if (fail) {
fail("Timeout while waiting for changes");
}
}
}
}
}

View File

@ -0,0 +1,172 @@
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.autoconfigure.ssl;
import java.nio.file.Path;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.boot.ssl.SslBundleRegistry;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.assertArg;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.times;
/**
* Tests for {@link SslPropertiesBundleRegistrar}.
*
* @author Moritz Halbritter
*/
class SslPropertiesBundleRegistrarTests {
private SslPropertiesBundleRegistrar registrar;
private FileWatcher fileWatcher;
private SslProperties properties;
private SslBundleRegistry registry;
@BeforeEach
void setUp() {
this.properties = new SslProperties();
this.fileWatcher = Mockito.mock(FileWatcher.class);
this.registrar = new SslPropertiesBundleRegistrar(this.properties, this.fileWatcher);
this.registry = Mockito.mock(SslBundleRegistry.class);
}
@Test
void shouldWatchJksBundles() {
JksSslBundleProperties jks = new JksSslBundleProperties();
jks.setReloadOnUpdate(true);
jks.getKeystore().setLocation("classpath:test.jks");
jks.getKeystore().setPassword("secret");
jks.getTruststore().setLocation("classpath:test.jks");
jks.getTruststore().setPassword("secret");
this.properties.getBundle().getJks().put("bundle1", jks);
this.registrar.registerBundles(this.registry);
then(this.registry).should(times(1)).registerBundle(eq("bundle1"), any());
then(this.fileWatcher).should().watch(assertArg((set) -> pathEndingWith(set, "test.jks")), any());
}
@Test
void shouldWatchPemBundles() {
PemSslBundleProperties pem = new PemSslBundleProperties();
pem.setReloadOnUpdate(true);
pem.getKeystore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/rsa-cert.pem");
pem.getKeystore().setPrivateKey("classpath:org/springframework/boot/autoconfigure/ssl/rsa-key.pem");
pem.getTruststore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem");
pem.getTruststore().setPrivateKey("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-key.pem");
this.properties.getBundle().getPem().put("bundle1", pem);
this.registrar.registerBundles(this.registry);
then(this.registry).should(times(1)).registerBundle(eq("bundle1"), any());
then(this.fileWatcher).should()
.watch(assertArg((set) -> pathEndingWith(set, "rsa-cert.pem", "rsa-key.pem")), any());
}
@Test
void shouldFailIfPemKeystoreCertificateIsEmbedded() {
PemSslBundleProperties pem = new PemSslBundleProperties();
pem.setReloadOnUpdate(true);
pem.getKeystore().setCertificate("""
-----BEGIN CERTIFICATE-----
MIICCzCCAb2gAwIBAgIUZbDi7G5czH+Yi0k2EMWxdf00XagwBQYDK2VwMHsxCzAJ
BgNVBAYTAlhYMRIwEAYDVQQIDAlTdGF0ZU5hbWUxETAPBgNVBAcMCENpdHlOYW1l
MRQwEgYDVQQKDAtDb21wYW55TmFtZTEbMBkGA1UECwwSQ29tcGFueVNlY3Rpb25O
YW1lMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjMwOTExMTIxNDMwWhcNMzMwOTA4
MTIxNDMwWjB7MQswCQYDVQQGEwJYWDESMBAGA1UECAwJU3RhdGVOYW1lMREwDwYD
VQQHDAhDaXR5TmFtZTEUMBIGA1UECgwLQ29tcGFueU5hbWUxGzAZBgNVBAsMEkNv
bXBhbnlTZWN0aW9uTmFtZTESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEA
Q/DDA4BSgZ+Hx0DUxtIRjVjN+OcxXVURwAWc3Gt9GUyjUzBRMB0GA1UdDgQWBBSv
EdpoaBMBoxgO96GFbf03k07DSTAfBgNVHSMEGDAWgBSvEdpoaBMBoxgO96GFbf03
k07DSTAPBgNVHRMBAf8EBTADAQH/MAUGAytlcANBAHMXDkGd57d4F4cRk/8UjhxD
7OtRBZfdfznSvlhJIMNfH5q0zbC2eO3hWCB3Hrn/vIeswGP8Ov4AJ6eXeX44BQM=
-----END CERTIFICATE-----
""".strip());
this.properties.getBundle().getPem().put("bundle1", pem);
assertThatIllegalStateException().isThrownBy(() -> this.registrar.registerBundles(this.registry))
.withMessage("SSL bundle 'bundle1' 'keystore.certificate' is not a URL and can't be watched");
}
@Test
void shouldFailIfPemKeystorePrivateKeyIsEmbedded() {
PemSslBundleProperties pem = new PemSslBundleProperties();
pem.setReloadOnUpdate(true);
pem.getKeystore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem");
pem.getKeystore().setPrivateKey("""
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIC29RnMVTcyqXEAIO1b/6p7RdbM6TiqvnztVQ4IxYxUh
-----END PRIVATE KEY-----
""".strip());
this.properties.getBundle().getPem().put("bundle1", pem);
assertThatIllegalStateException().isThrownBy(() -> this.registrar.registerBundles(this.registry))
.withMessage("SSL bundle 'bundle1' 'keystore.private-key' is not a URL and can't be watched");
}
@Test
void shouldFailIfPemTruststoreCertificateIsEmbedded() {
PemSslBundleProperties pem = new PemSslBundleProperties();
pem.setReloadOnUpdate(true);
pem.getTruststore().setCertificate("""
-----BEGIN CERTIFICATE-----
MIICCzCCAb2gAwIBAgIUZbDi7G5czH+Yi0k2EMWxdf00XagwBQYDK2VwMHsxCzAJ
BgNVBAYTAlhYMRIwEAYDVQQIDAlTdGF0ZU5hbWUxETAPBgNVBAcMCENpdHlOYW1l
MRQwEgYDVQQKDAtDb21wYW55TmFtZTEbMBkGA1UECwwSQ29tcGFueVNlY3Rpb25O
YW1lMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjMwOTExMTIxNDMwWhcNMzMwOTA4
MTIxNDMwWjB7MQswCQYDVQQGEwJYWDESMBAGA1UECAwJU3RhdGVOYW1lMREwDwYD
VQQHDAhDaXR5TmFtZTEUMBIGA1UECgwLQ29tcGFueU5hbWUxGzAZBgNVBAsMEkNv
bXBhbnlTZWN0aW9uTmFtZTESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEA
Q/DDA4BSgZ+Hx0DUxtIRjVjN+OcxXVURwAWc3Gt9GUyjUzBRMB0GA1UdDgQWBBSv
EdpoaBMBoxgO96GFbf03k07DSTAfBgNVHSMEGDAWgBSvEdpoaBMBoxgO96GFbf03
k07DSTAPBgNVHRMBAf8EBTADAQH/MAUGAytlcANBAHMXDkGd57d4F4cRk/8UjhxD
7OtRBZfdfznSvlhJIMNfH5q0zbC2eO3hWCB3Hrn/vIeswGP8Ov4AJ6eXeX44BQM=
-----END CERTIFICATE-----
""".strip());
this.properties.getBundle().getPem().put("bundle1", pem);
assertThatIllegalStateException().isThrownBy(() -> this.registrar.registerBundles(this.registry))
.withMessage("SSL bundle 'bundle1' 'truststore.certificate' is not a URL and can't be watched");
}
@Test
void shouldFailIfPemTruststorePrivateKeyIsEmbedded() {
PemSslBundleProperties pem = new PemSslBundleProperties();
pem.setReloadOnUpdate(true);
pem.getTruststore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem");
pem.getTruststore().setPrivateKey("""
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIC29RnMVTcyqXEAIO1b/6p7RdbM6TiqvnztVQ4IxYxUh
-----END PRIVATE KEY-----
""".strip());
this.properties.getBundle().getPem().put("bundle1", pem);
assertThatIllegalStateException().isThrownBy(() -> this.registrar.registerBundles(this.registry))
.withMessage("SSL bundle 'bundle1' 'truststore.private-key' is not a URL and can't be watched");
}
private void pathEndingWith(Set<Path> paths, String... suffixes) {
for (String suffix : suffixes) {
assertThat(paths).anyMatch((path) -> path.getFileName().toString().endsWith(suffix));
}
}
}

View File

@ -104,3 +104,33 @@ In addition, the `SslBundle` provides details about the key being used, the prot
The following example shows retrieving an `SslBundle` and using it to create an `SSLContext`:
include::code:MyComponent[]
[[features.ssl.reloading]]
=== Reloading SSL bundles
SSL bundles can be reloaded when the key material changes.
The component consuming the bundle has to be compatible with reloadable SSL bundles.
Currently the following components are compatible:
* Tomcat web server
* Netty web server
To enable reloading, you need to opt-in via a configuration property as shown in this example:
[source,yaml,indent=0,subs="verbatim",configblocks]
----
spring:
ssl:
bundle:
pem:
mybundle:
reload-on-update: true
keystore:
certificate: "file:/some/directory/application.crt"
private-key: "file:/some/directory/application.key"
----
A file watcher is then watching the files and if they change, the SSL bundle will be reloaded.
This in turn triggers a reload in the consuming component, e.g. Tomcat rotates the certificates in the SSL enabled connectors.
You can configure the quiet period (to make sure that there are no more changes) of the file watcher with the configprop:spring.ssl.bundle.watch.file.quiet-period[] property.

View File

@ -219,12 +219,15 @@ public class NettyRSocketServerFactory implements RSocketServerFactory, Configur
private static final class TcpSslServerCustomizer
extends org.springframework.boot.web.embedded.netty.SslServerCustomizer {
private final SslBundle sslBundle;
private TcpSslServerCustomizer(Ssl.ClientAuth clientAuth, SslBundle sslBundle) {
super(null, clientAuth, sslBundle);
this.sslBundle = sslBundle;
}
private TcpServer apply(TcpServer server) {
AbstractProtocolSslContextSpec<?> sslContextSpec = createSslContextSpec();
AbstractProtocolSslContextSpec<?> sslContextSpec = createSslContextSpec(this.sslBundle);
return server.secure((spec) -> spec.sslContext(sslContextSpec));
}

View File

@ -16,20 +16,31 @@
package org.springframework.boot.ssl;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.core.log.LogMessage;
import org.springframework.util.Assert;
/**
* Default {@link SslBundleRegistry} implementation.
*
* @author Scott Frederick
* @author Moritz Halbritter
* @author Phillip Webb
* @since 3.1.0
*/
public class DefaultSslBundleRegistry implements SslBundleRegistry, SslBundles {
private final Map<String, SslBundle> bundles = new ConcurrentHashMap<>();
private static final Log logger = LogFactory.getLog(DefaultSslBundleRegistry.class);
private final Map<String, RegisteredSslBundle> registeredBundles = new ConcurrentHashMap<>();
public DefaultSslBundleRegistry() {
}
@ -42,18 +53,67 @@ public class DefaultSslBundleRegistry implements SslBundleRegistry, SslBundles {
public void registerBundle(String name, SslBundle bundle) {
Assert.notNull(name, "Name must not be null");
Assert.notNull(bundle, "Bundle must not be null");
SslBundle previous = this.bundles.putIfAbsent(name, bundle);
RegisteredSslBundle previous = this.registeredBundles.putIfAbsent(name, new RegisteredSslBundle(name, bundle));
Assert.state(previous == null, () -> "Cannot replace existing SSL bundle '%s'".formatted(name));
}
@Override
public void updateBundle(String name, SslBundle updatedBundle) {
getRegistered(name).update(updatedBundle);
}
@Override
public SslBundle getBundle(String name) {
return getRegistered(name).getBundle();
}
@Override
public void addBundleUpdateHandler(String name, Consumer<SslBundle> updateHandler) throws NoSuchSslBundleException {
getRegistered(name).addUpdateHandler(updateHandler);
}
private RegisteredSslBundle getRegistered(String name) throws NoSuchSslBundleException {
Assert.notNull(name, "Name must not be null");
SslBundle bundle = this.bundles.get(name);
if (bundle == null) {
RegisteredSslBundle registered = this.registeredBundles.get(name);
if (registered == null) {
throw new NoSuchSslBundleException(name, "SSL bundle name '%s' cannot be found".formatted(name));
}
return bundle;
return registered;
}
private static class RegisteredSslBundle {
private final String name;
private final List<Consumer<SslBundle>> updateHandlers = new CopyOnWriteArrayList<>();
private volatile SslBundle bundle;
RegisteredSslBundle(String name, SslBundle bundle) {
this.name = name;
this.bundle = bundle;
}
void update(SslBundle updatedBundle) {
Assert.notNull(updatedBundle, "UpdatedBundle must not be null");
this.bundle = updatedBundle;
if (this.updateHandlers.isEmpty()) {
logger.warn(LogMessage.format(
"SSL bundle '%s' has been updated but may be in use by a technology that doesn't support SSL reloading",
this.name));
}
this.updateHandlers.forEach((handler) -> handler.accept(updatedBundle));
}
SslBundle getBundle() {
return this.bundle;
}
void addUpdateHandler(Consumer<SslBundle> updateHandler) {
Assert.notNull(updateHandler, "UpdateHandler must not be null");
this.updateHandlers.add(updateHandler);
}
}
}

View File

@ -20,6 +20,7 @@ package org.springframework.boot.ssl;
* Interface that can be used to register an {@link SslBundle} for a given name.
*
* @author Scott Frederick
* @author Moritz Halbritter
* @since 3.1.0
*/
public interface SslBundleRegistry {
@ -31,4 +32,13 @@ public interface SslBundleRegistry {
*/
void registerBundle(String name, SslBundle bundle);
/**
* Updates an {@link SslBundle}.
* @param name the bundle name
* @param updatedBundle the updated bundle
* @throws NoSuchSslBundleException if the bundle cannot be found
* @since 3.2.0
*/
void updateBundle(String name, SslBundle updatedBundle) throws NoSuchSslBundleException;
}

View File

@ -16,20 +16,32 @@
package org.springframework.boot.ssl;
import java.util.function.Consumer;
/**
* A managed set of {@link SslBundle} instances that can be retrieved by name.
*
* @author Scott Frederick
* @author Moritz Halbritter
* @since 3.1.0
*/
public interface SslBundles {
/**
* Return an {@link SslBundle} with the provided name.
* @param bundleName the bundle name
* @param name the bundle name
* @return the bundle
* @throws NoSuchSslBundleException if a bundle with the provided name does not exist
*/
SslBundle getBundle(String bundleName) throws NoSuchSslBundleException;
SslBundle getBundle(String name) throws NoSuchSslBundleException;
/**
* Add a handler that will be called each time the named bundle is updated.
* @param name the bundle name
* @param updateHandler the handler that should be called
* @throws NoSuchSslBundleException if a bundle with the provided name does not exist
* @since 3.2.0
*/
void addBundleUpdateHandler(String name, Consumer<SslBundle> updateHandler) throws NoSuchSslBundleException;
}

View File

@ -37,11 +37,13 @@ import org.springframework.http.client.ReactorResourceFactory;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* {@link ReactiveWebServerFactory} that can be used to create {@link NettyWebServer}s.
*
* @author Brian Clozel
* @author Moritz Halbritter
* @since 2.0.0
*/
public class NettyReactiveWebServerFactory extends AbstractReactiveWebServerFactory {
@ -170,7 +172,12 @@ public class NettyReactiveWebServerFactory extends AbstractReactiveWebServerFact
}
private HttpServer customizeSslConfiguration(HttpServer httpServer) {
return new SslServerCustomizer(getHttp2(), getSsl().getClientAuth(), getSslBundle()).apply(httpServer);
SslServerCustomizer customizer = new SslServerCustomizer(getHttp2(), getSsl().getClientAuth(), getSslBundle());
String bundleName = getSsl().getBundle();
if (StringUtils.hasText(bundleName)) {
getSslBundles().addBundleUpdateHandler(bundleName, customizer::updateSslBundle);
}
return customizer.apply(httpServer);
}
private HttpProtocol[] listProtocols() {

View File

@ -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<Object> 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
}
}

View File

@ -17,10 +17,14 @@
package org.springframework.boot.web.embedded.netty;
import io.netty.handler.ssl.ClientAuth;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.netty.http.Http11SslContextSpec;
import reactor.netty.http.Http2SslContextSpec;
import reactor.netty.http.server.HttpServer;
import reactor.netty.tcp.AbstractProtocolSslContextSpec;
import reactor.netty.tcp.SslProvider;
import reactor.netty.tcp.SslProvider.SslContextSpec;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslOptions;
@ -36,41 +40,77 @@ import org.springframework.boot.web.server.Ssl;
* @author Chris Bono
* @author Cyril Dangerville
* @author Scott Frederick
* @author Moritz Halbritter
* @author Phillip Webb
* @since 2.0.0
*/
public class SslServerCustomizer implements NettyServerCustomizer {
private static final Log logger = LogFactory.getLog(SslServerCustomizer.class);
private final Http2 http2;
private final Ssl.ClientAuth clientAuth;
private final ClientAuth clientAuth;
private final SslBundle sslBundle;
private volatile SslProvider sslProvider;
private volatile SslBundle sslBundle;
public SslServerCustomizer(Http2 http2, Ssl.ClientAuth clientAuth, SslBundle sslBundle) {
this.http2 = http2;
this.clientAuth = clientAuth;
this.clientAuth = Ssl.ClientAuth.map(clientAuth, ClientAuth.NONE, ClientAuth.OPTIONAL, ClientAuth.REQUIRE);
this.sslBundle = sslBundle;
this.sslProvider = createSslProvider(sslBundle);
}
@Override
public HttpServer apply(HttpServer server) {
AbstractProtocolSslContextSpec<?> sslContextSpec = createSslContextSpec();
return server.secure((spec) -> spec.sslContext(sslContextSpec));
return server.secure(this::applySecurity);
}
private void applySecurity(SslContextSpec spec) {
spec.sslContext(this.sslProvider.getSslContext())
.setSniAsyncMappings((domainName, promise) -> promise.setSuccess(this.sslProvider));
}
void updateSslBundle(SslBundle sslBundle) {
logger.debug("SSL Bundle has been updated, reloading SSL configuration");
this.sslBundle = sslBundle;
this.sslProvider = createSslProvider(sslBundle);
}
private SslProvider createSslProvider(SslBundle sslBundle) {
return SslProvider.builder().sslContext(createSslContextSpec(sslBundle)).build();
}
/**
* Factory method used to create an {@link AbstractProtocolSslContextSpec}.
* @return the {@link AbstractProtocolSslContextSpec} to use
* @deprecated since 3.2.0 for removal in 3.4.0 in favor of
* {@link #createSslContextSpec(SslBundle)}
*/
@Deprecated(since = "3.2", forRemoval = true)
protected AbstractProtocolSslContextSpec<?> createSslContextSpec() {
return createSslContextSpec(this.sslBundle);
}
/**
* Create an {@link AbstractProtocolSslContextSpec} for a given {@link SslBundle}.
* @param sslBundle the {@link SslBundle} to use
* @return an {@link AbstractProtocolSslContextSpec} instance
* @since 3.2.0
*/
protected final AbstractProtocolSslContextSpec<?> createSslContextSpec(SslBundle sslBundle) {
AbstractProtocolSslContextSpec<?> sslContextSpec = (this.http2 != null && this.http2.isEnabled())
? Http2SslContextSpec.forServer(this.sslBundle.getManagers().getKeyManagerFactory())
: Http11SslContextSpec.forServer(this.sslBundle.getManagers().getKeyManagerFactory());
sslContextSpec.configure((builder) -> {
builder.trustManager(this.sslBundle.getManagers().getTrustManagerFactory());
SslOptions options = this.sslBundle.getOptions();
? Http2SslContextSpec.forServer(sslBundle.getManagers().getKeyManagerFactory())
: Http11SslContextSpec.forServer(sslBundle.getManagers().getKeyManagerFactory());
return sslContextSpec.configure((builder) -> {
builder.trustManager(sslBundle.getManagers().getTrustManagerFactory());
SslOptions options = sslBundle.getOptions();
builder.protocols(options.getEnabledProtocols());
builder.ciphers(SslOptions.asSet(options.getCiphers()));
builder.clientAuth(org.springframework.boot.web.server.Ssl.ClientAuth.map(this.clientAuth, ClientAuth.NONE,
ClientAuth.OPTIONAL, ClientAuth.REQUIRE));
builder.clientAuth(this.clientAuth);
});
return sslContextSpec;
}
}

View File

@ -17,6 +17,7 @@
package org.springframework.boot.web.embedded.tomcat;
import org.apache.catalina.connector.Connector;
import org.apache.commons.logging.Log;
import org.apache.coyote.ProtocolHandler;
import org.apache.coyote.http11.AbstractHttp11JsseProtocol;
import org.apache.coyote.http11.Http11NioProtocol;
@ -33,48 +34,62 @@ import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* {@link TomcatConnectorCustomizer} that configures SSL support on the given connector.
* Utility that configures SSL support on the given connector.
*
* @author Brian Clozel
* @author Andy Wilkinson
* @author Scott Frederick
* @author Cyril Dangerville
* @author Moritz Halbritter
*/
class SslConnectorCustomizer implements TomcatConnectorCustomizer {
class SslConnectorCustomizer {
private final Log logger;
private final ClientAuth clientAuth;
private final SslBundle sslBundle;
private final Connector connector;
SslConnectorCustomizer(ClientAuth clientAuth, SslBundle sslBundle) {
SslConnectorCustomizer(Log logger, Connector connector, ClientAuth clientAuth) {
this.logger = logger;
this.clientAuth = clientAuth;
this.sslBundle = sslBundle;
this.connector = connector;
}
@Override
public void customize(Connector connector) {
ProtocolHandler handler = connector.getProtocolHandler();
void update(SslBundle updatedSslBundle) {
this.logger.debug("SSL Bundle has been updated, reloading SSL configuration");
customize(updatedSslBundle);
}
void customize(SslBundle sslBundle) {
ProtocolHandler handler = this.connector.getProtocolHandler();
Assert.state(handler instanceof AbstractHttp11JsseProtocol,
"To use SSL, the connector's protocol handler must be an AbstractHttp11JsseProtocol subclass");
configureSsl((AbstractHttp11JsseProtocol<?>) handler);
connector.setScheme("https");
connector.setSecure(true);
configureSsl(sslBundle, (AbstractHttp11JsseProtocol<?>) handler);
this.connector.setScheme("https");
this.connector.setSecure(true);
}
/**
* Configure Tomcat's {@link AbstractHttp11JsseProtocol} for SSL.
* @param sslBundle the SSL bundle
* @param protocol the protocol
*/
void configureSsl(AbstractHttp11JsseProtocol<?> protocol) {
SslBundleKey key = this.sslBundle.getKey();
SslStoreBundle stores = this.sslBundle.getStores();
SslOptions options = this.sslBundle.getOptions();
private void configureSsl(SslBundle sslBundle, AbstractHttp11JsseProtocol<?> protocol) {
protocol.setSSLEnabled(true);
SSLHostConfig sslHostConfig = new SSLHostConfig();
sslHostConfig.setHostName(protocol.getDefaultSSLHostConfigName());
sslHostConfig.setSslProtocol(this.sslBundle.getProtocol());
protocol.addSslHostConfig(sslHostConfig);
configureSslClientAuth(sslHostConfig);
applySslBundle(sslBundle, protocol, sslHostConfig);
protocol.addSslHostConfig(sslHostConfig, true);
}
private void applySslBundle(SslBundle sslBundle, AbstractHttp11JsseProtocol<?> protocol,
SSLHostConfig sslHostConfig) {
SslBundleKey key = sslBundle.getKey();
SslStoreBundle stores = sslBundle.getStores();
SslOptions options = sslBundle.getOptions();
sslHostConfig.setSslProtocol(sslBundle.getProtocol());
SSLHostConfigCertificate certificate = new SSLHostConfigCertificate(sslHostConfig, Type.UNDEFINED);
String keystorePassword = (stores.getKeyStorePassword() != null) ? stores.getKeyStorePassword() : "";
certificate.setCertificateKeystorePassword(keystorePassword);
@ -89,17 +104,14 @@ class SslConnectorCustomizer implements TomcatConnectorCustomizer {
String ciphers = StringUtils.arrayToCommaDelimitedString(options.getCiphers());
sslHostConfig.setCiphers(ciphers);
}
configureEnabledProtocols(protocol);
configureSslStoreProvider(protocol, sslHostConfig, certificate);
configureSslStoreProvider(protocol, sslHostConfig, certificate, stores);
configureEnabledProtocols(sslHostConfig, options);
}
private void configureEnabledProtocols(AbstractHttp11JsseProtocol<?> protocol) {
SslOptions options = this.sslBundle.getOptions();
private void configureEnabledProtocols(SSLHostConfig sslHostConfig, SslOptions options) {
if (options.getEnabledProtocols() != null) {
String enabledProtocols = StringUtils.arrayToDelimitedString(options.getEnabledProtocols(), "+");
for (SSLHostConfig sslHostConfig : protocol.findSslHostConfigs()) {
sslHostConfig.setProtocols(enabledProtocols);
}
sslHostConfig.setProtocols(enabledProtocols);
}
}
@ -107,12 +119,11 @@ class SslConnectorCustomizer implements TomcatConnectorCustomizer {
config.setCertificateVerification(ClientAuth.map(this.clientAuth, "none", "optional", "required"));
}
protected void configureSslStoreProvider(AbstractHttp11JsseProtocol<?> protocol, SSLHostConfig sslHostConfig,
SSLHostConfigCertificate certificate) {
private void configureSslStoreProvider(AbstractHttp11JsseProtocol<?> protocol, SSLHostConfig sslHostConfig,
SSLHostConfigCertificate certificate, SslStoreBundle stores) {
Assert.isInstanceOf(Http11NioProtocol.class, protocol,
"SslStoreProvider can only be used with Http11NioProtocol");
try {
SslStoreBundle stores = this.sslBundle.getStores();
if (stores.getKeyStore() != null) {
certificate.setCertificateKeystore(stores.getKeyStore());
}

View File

@ -35,6 +35,8 @@ import org.apache.catalina.connector.Connector;
import org.apache.catalina.core.AprLifecycleListener;
import org.apache.catalina.loader.WebappLoader;
import org.apache.catalina.startup.Tomcat;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.coyote.AbstractProtocol;
import org.apache.coyote.ProtocolHandler;
import org.apache.coyote.http2.Http2Protocol;
@ -57,11 +59,14 @@ import org.springframework.util.StringUtils;
*
* @author Brian Clozel
* @author HaiTao Zhang
* @author Moritz Halbritter
* @since 2.0.0
*/
public class TomcatReactiveWebServerFactory extends AbstractReactiveWebServerFactory
implements ConfigurableTomcatWebServerFactory {
private static final Log logger = LogFactory.getLog(TomcatReactiveWebServerFactory.class);
private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
/**
@ -224,7 +229,12 @@ public class TomcatReactiveWebServerFactory extends AbstractReactiveWebServerFac
}
private void customizeSsl(Connector connector) {
new SslConnectorCustomizer(getSsl().getClientAuth(), getSslBundle()).customize(connector);
SslConnectorCustomizer customizer = new SslConnectorCustomizer(logger, connector, getSsl().getClientAuth());
customizer.customize(getSslBundle());
String sslBundleName = getSsl().getBundle();
if (StringUtils.hasText(sslBundleName)) {
getSslBundles().addBundleUpdateHandler(sslBundleName, customizer::update);
}
}
@Override

View File

@ -62,6 +62,8 @@ import org.apache.catalina.util.SessionConfig;
import org.apache.catalina.webresources.AbstractResourceSet;
import org.apache.catalina.webresources.EmptyResource;
import org.apache.catalina.webresources.StandardRoot;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.coyote.AbstractProtocol;
import org.apache.coyote.ProtocolHandler;
import org.apache.coyote.http2.Http2Protocol;
@ -103,6 +105,7 @@ import org.springframework.util.StringUtils;
* @author Eddú Meléndez
* @author Christoffer Sawicki
* @author Dawid Antecki
* @author Moritz Halbritter
* @since 2.0.0
* @see #setPort(int)
* @see #setContextLifecycleListeners(Collection)
@ -111,6 +114,8 @@ import org.springframework.util.StringUtils;
public class TomcatServletWebServerFactory extends AbstractServletWebServerFactory
implements ConfigurableTomcatWebServerFactory, ResourceLoaderAware {
private static final Log logger = LogFactory.getLog(TomcatServletWebServerFactory.class);
private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
private static final Set<Class<?>> NO_CLASSES = Collections.emptySet();
@ -366,7 +371,12 @@ public class TomcatServletWebServerFactory extends AbstractServletWebServerFacto
}
private void customizeSsl(Connector connector) {
new SslConnectorCustomizer(getSsl().getClientAuth(), getSslBundle()).customize(connector);
SslConnectorCustomizer customizer = new SslConnectorCustomizer(logger, connector, getSsl().getClientAuth());
customizer.customize(getSslBundle());
String sslBundleName = getSsl().getBundle();
if (StringUtils.hasText(sslBundleName)) {
getSslBundles().addBundleUpdateHandler(sslBundleName, customizer::update);
}
}
/**

View File

@ -146,6 +146,15 @@ public abstract class AbstractConfigurableWebServerFactory implements Configurab
this.sslStoreProvider = sslStoreProvider;
}
/**
* Return the configured {@link SslBundles}.
* @return the {@link SslBundles} or {@code null}
* @since 3.2.0
*/
public SslBundles getSslBundles() {
return this.sslBundles;
}
@Override
public void setSslBundles(SslBundles sslBundles) {
this.sslBundles = sslBundles;

View File

@ -16,26 +16,43 @@
package org.springframework.boot.ssl;
import java.util.concurrent.atomic.AtomicReference;
import org.awaitility.Awaitility;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.testsupport.system.CapturedOutput;
import org.springframework.boot.testsupport.system.OutputCaptureExtension;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link DefaultSslBundleRegistry}.
*
* @author Phillip Webb
* @author Moritz Halbritter
*/
@ExtendWith(OutputCaptureExtension.class)
class DefaultSslBundleRegistryTests {
private SslBundle bundle1 = mock(SslBundle.class);
private final SslBundle bundle1 = mock(SslBundle.class);
private SslBundle bundle2 = mock(SslBundle.class);
private final SslBundle bundle2 = mock(SslBundle.class);
private DefaultSslBundleRegistry registry = new DefaultSslBundleRegistry();
private DefaultSslBundleRegistry registry;
@BeforeEach
void setUp() {
this.registry = new DefaultSslBundleRegistry();
}
@Test
void createWithNameAndBundleRegistersBundle() {
@ -89,4 +106,29 @@ class DefaultSslBundleRegistryTests {
assertThat(this.registry.getBundle("test2")).isSameAs(this.bundle2);
}
@Test
void updateBundleShouldNotifyUpdateHandlers() {
AtomicReference<SslBundle> updatedBundle = new AtomicReference<>();
this.registry.registerBundle("test1", this.bundle1);
this.registry.addBundleUpdateHandler("test1", updatedBundle::set);
this.registry.updateBundle("test1", this.bundle2);
Awaitility.await().untilAtomic(updatedBundle, Matchers.equalTo(this.bundle2));
}
@Test
void shouldFailIfUpdatingNonRegisteredBundle() {
assertThatThrownBy(() -> this.registry.updateBundle("dummy", this.bundle1))
.isInstanceOf(NoSuchSslBundleException.class)
.hasMessageContaining("'dummy'");
}
@Test
void shouldLogIfUpdatingBundleWithoutListeners(CapturedOutput output) {
this.registry.registerBundle("test1", this.bundle1);
this.registry.getBundle("test1");
this.registry.updateBundle("test1", this.bundle2);
assertThat(output).contains(
"SSL bundle 'test1' has been updated but may be in use by a technology that doesn't support SSL reloading");
}
}

View File

@ -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")

View File

@ -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<String> result = testSslWithBundle(bundles, "bundle1");
StepVerifier.create(result).expectNext("Hello World").expectComplete().verify(Duration.ofSeconds(30));
bundles.updateBundle("bundle1", createSslBundle("2.key", "2.crt"));
Mono<String> 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<String> testSslWithAlias(String alias) {
private Mono<String> 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<String> 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<String> 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) {

View File

@ -27,6 +27,8 @@ import java.util.Set;
import org.apache.catalina.LifecycleState;
import org.apache.catalina.connector.Connector;
import org.apache.catalina.startup.Tomcat;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.tomcat.util.net.SSLHostConfig;
import org.apache.tomcat.util.net.SSLHostConfigCertificate;
import org.junit.jupiter.api.AfterEach;
@ -65,6 +67,8 @@ import static org.mockito.Mockito.mock;
@MockPkcs11Security
class SslConnectorCustomizerTests {
private final Log logger = LogFactory.getLog(SslConnectorCustomizerTests.class);
private Tomcat tomcat;
@BeforeEach
@ -87,10 +91,9 @@ class SslConnectorCustomizerTests {
ssl.setKeyStore("classpath:test.jks");
ssl.setKeyStorePassword("secret");
ssl.setCiphers(new String[] { "ALPHA", "BRAVO", "CHARLIE" });
SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(),
WebServerSslBundle.get(ssl));
Connector connector = this.tomcat.getConnector();
customizer.customize(connector);
SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth());
customizer.customize(WebServerSslBundle.get(ssl));
this.tomcat.start();
SSLHostConfig[] sslHostConfigs = connector.getProtocolHandler().findSslHostConfigs();
assertThat(sslHostConfigs[0].getCiphers()).isEqualTo("ALPHA:BRAVO:CHARLIE");
@ -103,10 +106,9 @@ class SslConnectorCustomizerTests {
ssl.setKeyStore("src/test/resources/test.jks");
ssl.setEnabledProtocols(new String[] { "TLSv1.1", "TLSv1.2" });
ssl.setCiphers(new String[] { "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", "BRAVO" });
SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(),
WebServerSslBundle.get(ssl));
Connector connector = this.tomcat.getConnector();
customizer.customize(connector);
SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth());
customizer.customize(WebServerSslBundle.get(ssl));
this.tomcat.start();
SSLHostConfig sslHostConfig = connector.getProtocolHandler().findSslHostConfigs()[0];
assertThat(sslHostConfig.getSslProtocol()).isEqualTo("TLS");
@ -120,10 +122,9 @@ class SslConnectorCustomizerTests {
ssl.setKeyStore("src/test/resources/test.jks");
ssl.setEnabledProtocols(new String[] { "TLSv1.2" });
ssl.setCiphers(new String[] { "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", "BRAVO" });
SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(),
WebServerSslBundle.get(ssl));
Connector connector = this.tomcat.getConnector();
customizer.customize(connector);
SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth());
customizer.customize(WebServerSslBundle.get(ssl));
this.tomcat.start();
SSLHostConfig sslHostConfig = connector.getProtocolHandler().findSslHostConfigs()[0];
assertThat(sslHostConfig.getSslProtocol()).isEqualTo("TLS");
@ -139,10 +140,9 @@ class SslConnectorCustomizerTests {
SslStoreProvider sslStoreProvider = mock(SslStoreProvider.class);
KeyStore keyStore = loadStore();
given(sslStoreProvider.getKeyStore()).willReturn(keyStore);
SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(),
WebServerSslBundle.get(ssl, null, sslStoreProvider));
Connector connector = this.tomcat.getConnector();
customizer.customize(connector);
SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth());
customizer.customize(WebServerSslBundle.get(ssl, null, sslStoreProvider));
this.tomcat.start();
SSLHostConfig sslHostConfig = connector.getProtocolHandler().findSslHostConfigs()[0];
SSLHostConfig sslHostConfigWithDefaults = new SSLHostConfig();
@ -161,10 +161,9 @@ class SslConnectorCustomizerTests {
SslStoreProvider sslStoreProvider = mock(SslStoreProvider.class);
KeyStore trustStore = loadStore();
given(sslStoreProvider.getTrustStore()).willReturn(trustStore);
SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(),
WebServerSslBundle.get(ssl, null, sslStoreProvider));
Connector connector = this.tomcat.getConnector();
customizer.customize(connector);
SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth());
customizer.customize(WebServerSslBundle.get(ssl, null, sslStoreProvider));
this.tomcat.start();
SSLHostConfig sslHostConfig = connector.getProtocolHandler().findSslHostConfigs()[0];
assertThat(sslHostConfig.getTruststore()).isEqualTo(trustStore);
@ -180,10 +179,9 @@ class SslConnectorCustomizerTests {
SslStoreProvider sslStoreProvider = mock(SslStoreProvider.class);
given(sslStoreProvider.getTrustStore()).willReturn(loadStore());
given(sslStoreProvider.getKeyStore()).willReturn(loadStore());
SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(),
WebServerSslBundle.get(ssl, null, sslStoreProvider));
Connector connector = this.tomcat.getConnector();
customizer.customize(connector);
SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth());
customizer.customize(WebServerSslBundle.get(ssl, null, sslStoreProvider));
this.tomcat.start();
assertThat(connector.getState()).isEqualTo(LifecycleState.STARTED);
assertThat(output).doesNotContain("Password verification failed");
@ -192,9 +190,9 @@ class SslConnectorCustomizerTests {
@Test
void customizeWhenSslIsEnabledWithNoKeyStoreAndNotPkcs11ThrowsException() {
assertThatIllegalStateException().isThrownBy(() -> {
SslConnectorCustomizer customizer = new SslConnectorCustomizer(Ssl.ClientAuth.NONE,
WebServerSslBundle.get(new Ssl()));
customizer.customize(this.tomcat.getConnector());
SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, this.tomcat.getConnector(),
Ssl.ClientAuth.NONE);
customizer.customize(WebServerSslBundle.get(new Ssl()));
}).withMessageContaining("SSL is enabled but no trust material is configured");
}
@ -206,9 +204,9 @@ class SslConnectorCustomizerTests {
ssl.setKeyStore("src/test/resources/test.jks");
ssl.setKeyPassword("password");
assertThatIllegalStateException().isThrownBy(() -> {
SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(),
WebServerSslBundle.get(ssl));
customizer.customize(this.tomcat.getConnector());
SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, this.tomcat.getConnector(),
ssl.getClientAuth());
customizer.customize(WebServerSslBundle.get(ssl));
}).withMessageContaining("must be empty or null for PKCS11 hardware key stores");
}
@ -218,9 +216,9 @@ class SslConnectorCustomizerTests {
ssl.setKeyStoreType("PKCS11");
ssl.setKeyStoreProvider(MockPkcs11SecurityProvider.NAME);
ssl.setKeyStorePassword("1234");
SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(),
WebServerSslBundle.get(ssl));
assertThatNoException().isThrownBy(() -> customizer.customize(this.tomcat.getConnector()));
SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, this.tomcat.getConnector(),
ssl.getClientAuth());
assertThatNoException().isThrownBy(() -> customizer.customize(WebServerSslBundle.get(ssl)));
}
private KeyStore loadStore() throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException {

View File

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

View File

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

View File

@ -0,0 +1,9 @@
-----BEGIN CERTIFICATE-----
MIIBLjCB4aADAgECAhQ25wrNnapZEkFc8kgf5NDHXKxnTzAFBgMrZXAwDDEKMAgG
A1UEAwwBMTAgFw0yMzEwMTAwODU1MTJaGA8yMTIzMDkxNjA4NTUxMlowDDEKMAgG
A1UEAwwBMTAqMAUGAytlcAMhAOyxNxHzcNj7xTkcjVLI09sYUGUGIvdV5s0YWXT8
XAiwo1MwUTAdBgNVHQ4EFgQUmm23oLIu5MgdBb/snZSuE+MrRZ0wHwYDVR0jBBgw
FoAUmm23oLIu5MgdBb/snZSuE+MrRZ0wDwYDVR0TAQH/BAUwAwEB/zAFBgMrZXAD
QQA2KMpIyySC8u4onW2MVW1iK2dJJZbMRaNMLlQuE+ZIHQLwflYW4sH/Pp76pboc
QhqKXcO7xH7f2tD5hE2izcUB
-----END CERTIFICATE-----

View File

@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIJb1A+i5bmilBD9mUbhk1oFVI6FAZQGnhduv7xV6WWEc
-----END PRIVATE KEY-----

View File

@ -0,0 +1,9 @@
-----BEGIN CERTIFICATE-----
MIIBLjCB4aADAgECAhR4TMDk3qg5sKREp16lEHR3bV3M9zAFBgMrZXAwDDEKMAgG
A1UEAwwBMjAgFw0yMzEwMTAwODU1MjBaGA8yMTIzMDkxNjA4NTUyMFowDDEKMAgG
A1UEAwwBMjAqMAUGAytlcAMhADPft6hzyCjHCe5wSprChuuO/CuPIJ2t+l4roS1D
43/wo1MwUTAdBgNVHQ4EFgQUfrRibAWml4Ous4kpnBIggM2xnLcwHwYDVR0jBBgw
FoAUfrRibAWml4Ous4kpnBIggM2xnLcwDwYDVR0TAQH/BAUwAwEB/zAFBgMrZXAD
QQC/MOclal2Cp0B3kmaLbK0M8mapclIOJa78hzBkqPA3URClAF2GmF187wHqi7qV
+xZ+KWv26pLJR46vk8Kc6ZIO
-----END CERTIFICATE-----

View File

@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEICxhres2Z2lICm7/isnm+2iNR12GmgG7KK86BNDZDeIF
-----END PRIVATE KEY-----

View File

@ -0,0 +1,9 @@
-----BEGIN CERTIFICATE-----
MIIBLjCB4aADAgECAhQ25wrNnapZEkFc8kgf5NDHXKxnTzAFBgMrZXAwDDEKMAgG
A1UEAwwBMTAgFw0yMzEwMTAwODU1MTJaGA8yMTIzMDkxNjA4NTUxMlowDDEKMAgG
A1UEAwwBMTAqMAUGAytlcAMhAOyxNxHzcNj7xTkcjVLI09sYUGUGIvdV5s0YWXT8
XAiwo1MwUTAdBgNVHQ4EFgQUmm23oLIu5MgdBb/snZSuE+MrRZ0wHwYDVR0jBBgw
FoAUmm23oLIu5MgdBb/snZSuE+MrRZ0wDwYDVR0TAQH/BAUwAwEB/zAFBgMrZXAD
QQA2KMpIyySC8u4onW2MVW1iK2dJJZbMRaNMLlQuE+ZIHQLwflYW4sH/Pp76pboc
QhqKXcO7xH7f2tD5hE2izcUB
-----END CERTIFICATE-----

View File

@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIJb1A+i5bmilBD9mUbhk1oFVI6FAZQGnhduv7xV6WWEc
-----END PRIVATE KEY-----

View File

@ -0,0 +1,9 @@
-----BEGIN CERTIFICATE-----
MIIBLjCB4aADAgECAhR4TMDk3qg5sKREp16lEHR3bV3M9zAFBgMrZXAwDDEKMAgG
A1UEAwwBMjAgFw0yMzEwMTAwODU1MjBaGA8yMTIzMDkxNjA4NTUyMFowDDEKMAgG
A1UEAwwBMjAqMAUGAytlcAMhADPft6hzyCjHCe5wSprChuuO/CuPIJ2t+l4roS1D
43/wo1MwUTAdBgNVHQ4EFgQUfrRibAWml4Ous4kpnBIggM2xnLcwHwYDVR0jBBgw
FoAUfrRibAWml4Ous4kpnBIggM2xnLcwDwYDVR0TAQH/BAUwAwEB/zAFBgMrZXAD
QQC/MOclal2Cp0B3kmaLbK0M8mapclIOJa78hzBkqPA3URClAF2GmF187wHqi7qV
+xZ+KWv26pLJR46vk8Kc6ZIO
-----END CERTIFICATE-----

View File

@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEICxhres2Z2lICm7/isnm+2iNR12GmgG7KK86BNDZDeIF
-----END PRIVATE KEY-----