mirror of
https://github.com/spring-projects/spring-boot.git
synced 2024-08-29 03:06:45 +08:00
Implement SSL hot reload for Netty and Tomcat
Closes gh-37808
This commit is contained in:
parent
6f5688ad3e
commit
19fd88b25b
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
||||
/**
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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.
|
||||
|
@ -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));
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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());
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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;
|
||||
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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")
|
||||
|
@ -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) {
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -0,0 +1,9 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBLjCB4aADAgECAhQ25wrNnapZEkFc8kgf5NDHXKxnTzAFBgMrZXAwDDEKMAgG
|
||||
A1UEAwwBMTAgFw0yMzEwMTAwODU1MTJaGA8yMTIzMDkxNjA4NTUxMlowDDEKMAgG
|
||||
A1UEAwwBMTAqMAUGAytlcAMhAOyxNxHzcNj7xTkcjVLI09sYUGUGIvdV5s0YWXT8
|
||||
XAiwo1MwUTAdBgNVHQ4EFgQUmm23oLIu5MgdBb/snZSuE+MrRZ0wHwYDVR0jBBgw
|
||||
FoAUmm23oLIu5MgdBb/snZSuE+MrRZ0wDwYDVR0TAQH/BAUwAwEB/zAFBgMrZXAD
|
||||
QQA2KMpIyySC8u4onW2MVW1iK2dJJZbMRaNMLlQuE+ZIHQLwflYW4sH/Pp76pboc
|
||||
QhqKXcO7xH7f2tD5hE2izcUB
|
||||
-----END CERTIFICATE-----
|
@ -0,0 +1,3 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIJb1A+i5bmilBD9mUbhk1oFVI6FAZQGnhduv7xV6WWEc
|
||||
-----END PRIVATE KEY-----
|
@ -0,0 +1,9 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBLjCB4aADAgECAhR4TMDk3qg5sKREp16lEHR3bV3M9zAFBgMrZXAwDDEKMAgG
|
||||
A1UEAwwBMjAgFw0yMzEwMTAwODU1MjBaGA8yMTIzMDkxNjA4NTUyMFowDDEKMAgG
|
||||
A1UEAwwBMjAqMAUGAytlcAMhADPft6hzyCjHCe5wSprChuuO/CuPIJ2t+l4roS1D
|
||||
43/wo1MwUTAdBgNVHQ4EFgQUfrRibAWml4Ous4kpnBIggM2xnLcwHwYDVR0jBBgw
|
||||
FoAUfrRibAWml4Ous4kpnBIggM2xnLcwDwYDVR0TAQH/BAUwAwEB/zAFBgMrZXAD
|
||||
QQC/MOclal2Cp0B3kmaLbK0M8mapclIOJa78hzBkqPA3URClAF2GmF187wHqi7qV
|
||||
+xZ+KWv26pLJR46vk8Kc6ZIO
|
||||
-----END CERTIFICATE-----
|
@ -0,0 +1,3 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEICxhres2Z2lICm7/isnm+2iNR12GmgG7KK86BNDZDeIF
|
||||
-----END PRIVATE KEY-----
|
@ -0,0 +1,9 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBLjCB4aADAgECAhQ25wrNnapZEkFc8kgf5NDHXKxnTzAFBgMrZXAwDDEKMAgG
|
||||
A1UEAwwBMTAgFw0yMzEwMTAwODU1MTJaGA8yMTIzMDkxNjA4NTUxMlowDDEKMAgG
|
||||
A1UEAwwBMTAqMAUGAytlcAMhAOyxNxHzcNj7xTkcjVLI09sYUGUGIvdV5s0YWXT8
|
||||
XAiwo1MwUTAdBgNVHQ4EFgQUmm23oLIu5MgdBb/snZSuE+MrRZ0wHwYDVR0jBBgw
|
||||
FoAUmm23oLIu5MgdBb/snZSuE+MrRZ0wDwYDVR0TAQH/BAUwAwEB/zAFBgMrZXAD
|
||||
QQA2KMpIyySC8u4onW2MVW1iK2dJJZbMRaNMLlQuE+ZIHQLwflYW4sH/Pp76pboc
|
||||
QhqKXcO7xH7f2tD5hE2izcUB
|
||||
-----END CERTIFICATE-----
|
@ -0,0 +1,3 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIJb1A+i5bmilBD9mUbhk1oFVI6FAZQGnhduv7xV6WWEc
|
||||
-----END PRIVATE KEY-----
|
@ -0,0 +1,9 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBLjCB4aADAgECAhR4TMDk3qg5sKREp16lEHR3bV3M9zAFBgMrZXAwDDEKMAgG
|
||||
A1UEAwwBMjAgFw0yMzEwMTAwODU1MjBaGA8yMTIzMDkxNjA4NTUyMFowDDEKMAgG
|
||||
A1UEAwwBMjAqMAUGAytlcAMhADPft6hzyCjHCe5wSprChuuO/CuPIJ2t+l4roS1D
|
||||
43/wo1MwUTAdBgNVHQ4EFgQUfrRibAWml4Ous4kpnBIggM2xnLcwHwYDVR0jBBgw
|
||||
FoAUfrRibAWml4Ous4kpnBIggM2xnLcwDwYDVR0TAQH/BAUwAwEB/zAFBgMrZXAD
|
||||
QQC/MOclal2Cp0B3kmaLbK0M8mapclIOJa78hzBkqPA3URClAF2GmF187wHqi7qV
|
||||
+xZ+KWv26pLJR46vk8Kc6ZIO
|
||||
-----END CERTIFICATE-----
|
@ -0,0 +1,3 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEICxhres2Z2lICm7/isnm+2iNR12GmgG7KK86BNDZDeIF
|
||||
-----END PRIVATE KEY-----
|
Loading…
Reference in New Issue
Block a user