Implement SSL hot reload for Netty and Tomcat

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@
package org.springframework.boot.autoconfigure.ssl;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -25,6 +26,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* Properties for centralized SSL trust material configuration.
*
* @author Scott Frederick
* @author Moritz Halbritter
* @since 3.1.0
*/
@ConfigurationProperties(prefix = "spring.ssl")
@@ -54,6 +56,11 @@ public class SslProperties {
*/
private final Map<String, JksSslBundleProperties> jks = new LinkedHashMap<>();
/**
* Trust material watching.
*/
private final Watch watch = new Watch();
public Map<String, PemSslBundleProperties> getPem() {
return this.pem;
}
@@ -62,6 +69,40 @@ public class SslProperties {
return this.jks;
}
public Watch getWatch() {
return this.watch;
}
public static class Watch {
/**
* File watching.
*/
private final File file = new File();
public File getFile() {
return this.file;
}
public static class File {
/**
* Quiet period, after which changes are detected.
*/
private Duration quietPeriod = Duration.ofSeconds(10);
public Duration getQuietPeriod() {
return this.quietPeriod;
}
public void setQuietPeriod(Duration quietPeriod) {
this.quietPeriod = quietPeriod;
}
}
}
}
}

View File

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

View File

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

View File

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