Implement SSL hot reload for Netty and Tomcat
Closes gh-37808
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user