Implement SSL hot reload
parent
7b1059a4b5
commit
c9e45952b0
@ -0,0 +1,302 @@
|
||||
/*
|
||||
* Copyright 2012-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.boot.autoconfigure.ssl;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.FileSystems;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardWatchEventKinds;
|
||||
import java.nio.file.WatchEvent;
|
||||
import java.nio.file.WatchEvent.Kind;
|
||||
import java.nio.file.WatchKey;
|
||||
import java.nio.file.WatchService;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
|
||||
import org.springframework.core.log.LogMessage;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* Watches files and directories and triggers a callback on change.
|
||||
*
|
||||
* @author Moritz Halbritter
|
||||
*/
|
||||
class FileWatcher implements AutoCloseable {
|
||||
|
||||
private static final Log logger = LogFactory.getLog(FileWatcher.class);
|
||||
|
||||
private final String threadName;
|
||||
|
||||
private final Duration quietPeriod;
|
||||
|
||||
private final Object lifecycleLock = new Object();
|
||||
|
||||
private final Map<WatchKey, List<Registration>> registrations = new ConcurrentHashMap<>();
|
||||
|
||||
private volatile WatchService watchService;
|
||||
|
||||
private Thread thread;
|
||||
|
||||
private boolean running = false;
|
||||
|
||||
FileWatcher(String threadName, Duration quietPeriod) {
|
||||
Assert.notNull(threadName, "threadName must not be null");
|
||||
Assert.notNull(quietPeriod, "quietPeriod must not be null");
|
||||
this.threadName = threadName;
|
||||
this.quietPeriod = quietPeriod;
|
||||
}
|
||||
|
||||
void watch(Set<Path> paths, Callback callback) {
|
||||
Assert.notNull(callback, "callback must not be null");
|
||||
Assert.notNull(paths, "paths must not be null");
|
||||
if (paths.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
startIfNecessary();
|
||||
try {
|
||||
registerWatchables(callback, paths, this.watchService);
|
||||
}
|
||||
catch (IOException ex) {
|
||||
throw new UncheckedIOException("Failed to register paths for watching: " + paths, ex);
|
||||
}
|
||||
}
|
||||
|
||||
void stop() {
|
||||
synchronized (this.lifecycleLock) {
|
||||
if (!this.running) {
|
||||
return;
|
||||
}
|
||||
this.running = false;
|
||||
this.thread.interrupt();
|
||||
try {
|
||||
this.thread.join();
|
||||
}
|
||||
catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
this.thread = null;
|
||||
this.watchService = null;
|
||||
this.registrations.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void startIfNecessary() {
|
||||
synchronized (this.lifecycleLock) {
|
||||
if (this.running) {
|
||||
return;
|
||||
}
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
this.thread = new Thread(() -> this.threadMain(started));
|
||||
this.thread.setName(this.threadName);
|
||||
this.thread.setDaemon(true);
|
||||
this.thread.setUncaughtExceptionHandler(this::onThreadException);
|
||||
this.running = true;
|
||||
this.thread.start();
|
||||
try {
|
||||
started.await();
|
||||
}
|
||||
catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void threadMain(CountDownLatch started) {
|
||||
logger.debug("Watch thread started");
|
||||
try (WatchService watcher = FileSystems.getDefault().newWatchService()) {
|
||||
this.watchService = watcher;
|
||||
started.countDown();
|
||||
Map<Registration, List<Change>> accumulatedChanges = new HashMap<>();
|
||||
while (this.running) {
|
||||
try {
|
||||
WatchKey key = watcher.poll(this.quietPeriod.toMillis(), TimeUnit.MILLISECONDS);
|
||||
if (key == null) {
|
||||
// WatchService returned without any changes
|
||||
if (!accumulatedChanges.isEmpty()) {
|
||||
// We have queued changes, that means there were no changes
|
||||
// since the quiet period
|
||||
fireCallback(accumulatedChanges);
|
||||
accumulatedChanges.clear();
|
||||
}
|
||||
}
|
||||
else {
|
||||
accumulateChanges(key, accumulatedChanges);
|
||||
}
|
||||
}
|
||||
catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
logger.debug("Watch thread stopped");
|
||||
}
|
||||
catch (IOException ex) {
|
||||
throw new UncheckedIOException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void accumulateChanges(WatchKey key, Map<Registration, List<Change>> accumulatedChanges)
|
||||
throws IOException {
|
||||
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.affectsFile(file)) {
|
||||
accumulatedChanges.computeIfAbsent(registration, (ignore) -> new ArrayList<>())
|
||||
.add(new Change(file, Type.from(event.kind())));
|
||||
}
|
||||
}
|
||||
}
|
||||
key.reset();
|
||||
}
|
||||
|
||||
private void fireCallback(Map<Registration, List<Change>> accumulatedChanges) {
|
||||
for (Entry<Registration, List<Change>> entry : accumulatedChanges.entrySet()) {
|
||||
Changes changes = new Changes(entry.getValue());
|
||||
if (!changes.isEmpty()) {
|
||||
entry.getKey().callback().onChange(changes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void onThreadException(Thread thread, Throwable throwable) {
|
||||
logger.error("Uncaught exception in file watcher thread", throwable);
|
||||
}
|
||||
|
||||
private void registerWatchables(Callback callback, Set<Path> paths, WatchService watchService) throws IOException {
|
||||
Set<WatchKey> watchKeys = new HashSet<>();
|
||||
Set<Path> directories = new HashSet<>();
|
||||
Set<Path> files = new HashSet<>();
|
||||
for (Path path : paths) {
|
||||
Path realPath = path.toRealPath();
|
||||
if (Files.isDirectory(realPath)) {
|
||||
directories.add(realPath);
|
||||
watchKeys.add(registerDirectory(realPath, watchService));
|
||||
}
|
||||
else if (Files.isRegularFile(realPath)) {
|
||||
files.add(realPath);
|
||||
watchKeys.add(registerFile(realPath, watchService));
|
||||
}
|
||||
else {
|
||||
throw new IOException("'%s' is neither a file nor a directory".formatted(realPath));
|
||||
}
|
||||
}
|
||||
Registration registration = new Registration(callback, directories, files);
|
||||
for (WatchKey watchKey : watchKeys) {
|
||||
this.registrations.computeIfAbsent(watchKey, (ignore) -> new CopyOnWriteArrayList<>()).add(registration);
|
||||
}
|
||||
}
|
||||
|
||||
private WatchKey registerFile(Path file, WatchService watchService) throws IOException {
|
||||
return register(file.getParent(), watchService);
|
||||
}
|
||||
|
||||
private WatchKey registerDirectory(Path directory, WatchService watchService) throws IOException {
|
||||
return register(directory, watchService);
|
||||
}
|
||||
|
||||
private WatchKey register(Path directory, WatchService watchService) throws IOException {
|
||||
logger.debug(LogMessage.format("Registering '%s'", directory));
|
||||
return directory.register(watchService, StandardWatchEventKinds.ENTRY_CREATE,
|
||||
StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
stop();
|
||||
}
|
||||
|
||||
private record Registration(Callback callback, Set<Path> directories, Set<Path> files) {
|
||||
boolean affectsFile(Path file) {
|
||||
return this.files.contains(file) || isInDirectories(file);
|
||||
}
|
||||
|
||||
private boolean isInDirectories(Path file) {
|
||||
for (Path directory : this.directories) {
|
||||
if (file.startsWith(directory)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
enum Type {
|
||||
|
||||
CREATE, MODIFY, DELETE;
|
||||
|
||||
private static Type from(Kind<?> kind) {
|
||||
if (kind == StandardWatchEventKinds.ENTRY_CREATE) {
|
||||
return CREATE;
|
||||
}
|
||||
if (kind == StandardWatchEventKinds.ENTRY_DELETE) {
|
||||
return DELETE;
|
||||
}
|
||||
if (kind == StandardWatchEventKinds.ENTRY_MODIFY) {
|
||||
return MODIFY;
|
||||
}
|
||||
throw new IllegalArgumentException("Unknown kind: " + kind);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
record Change(Path path, Type type) {
|
||||
}
|
||||
|
||||
static class Changes implements Iterable<Change> {
|
||||
|
||||
private final List<Change> changes;
|
||||
|
||||
Changes(List<Change> changes) {
|
||||
this.changes = changes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterator<Change> iterator() {
|
||||
return this.changes.iterator();
|
||||
}
|
||||
|
||||
boolean isEmpty() {
|
||||
return this.changes.isEmpty();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface Callback {
|
||||
|
||||
void onChange(Changes changes);
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,179 @@
|
||||
/*
|
||||
* Copyright 2012-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.boot.autoconfigure.ssl;
|
||||
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.apache.activemq.artemis.utils.collections.ConcurrentHashSet;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import org.springframework.boot.autoconfigure.ssl.FileWatcher.Callback;
|
||||
import org.springframework.boot.autoconfigure.ssl.FileWatcher.Change;
|
||||
import org.springframework.boot.autoconfigure.ssl.FileWatcher.Changes;
|
||||
import org.springframework.boot.autoconfigure.ssl.FileWatcher.Type;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.assertj.core.api.Assertions.fail;
|
||||
|
||||
/**
|
||||
* Tests for {@link FileWatcher}.
|
||||
*
|
||||
* @author Moritz Halbritter
|
||||
*/
|
||||
class FileWatcherTests {
|
||||
|
||||
private FileWatcher fileWatcher;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
this.fileWatcher = new FileWatcher("filewatcher-test-", Duration.ofMillis(10));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
this.fileWatcher.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldTriggerOnFileCreation(@TempDir Path tempDir) throws Exception {
|
||||
Path newFile = tempDir.resolve("new-file.txt");
|
||||
WaitingCallback callback = new WaitingCallback();
|
||||
this.fileWatcher.watch(Set.of(tempDir), callback);
|
||||
Files.createFile(newFile);
|
||||
Set<Change> changes = callback.waitForChanges();
|
||||
assertThatHasChanges(changes, new Change(newFile, Type.CREATE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldTriggerOnFileDeletion(@TempDir Path tempDir) throws Exception {
|
||||
Path deletedFile = tempDir.resolve("deleted-file.txt");
|
||||
Files.createFile(deletedFile);
|
||||
WaitingCallback callback = new WaitingCallback();
|
||||
this.fileWatcher.watch(Set.of(tempDir), callback);
|
||||
Files.delete(deletedFile);
|
||||
Set<Change> changes = callback.waitForChanges();
|
||||
assertThatHasChanges(changes, new Change(deletedFile, Type.DELETE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldTriggerOnFileModification(@TempDir Path tempDir) throws Exception {
|
||||
Path deletedFile = tempDir.resolve("modified-file.txt");
|
||||
Files.createFile(deletedFile);
|
||||
WaitingCallback callback = new WaitingCallback();
|
||||
this.fileWatcher.watch(Set.of(tempDir), callback);
|
||||
Files.writeString(deletedFile, "Some content");
|
||||
Set<Change> changes = callback.waitForChanges();
|
||||
assertThatHasChanges(changes, new Change(deletedFile, Type.MODIFY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldWatchFile(@TempDir Path tempDir) throws Exception {
|
||||
Path watchedFile = tempDir.resolve("watched.txt");
|
||||
Files.createFile(watchedFile);
|
||||
WaitingCallback callback = new WaitingCallback();
|
||||
this.fileWatcher.watch(Set.of(watchedFile), callback);
|
||||
Files.writeString(watchedFile, "Some content");
|
||||
Set<Change> changes = callback.waitForChanges();
|
||||
assertThatHasChanges(changes, new Change(watchedFile, Type.MODIFY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIgnoreNotWatchedFiles(@TempDir Path tempDir) throws Exception {
|
||||
Path watchedFile = tempDir.resolve("watched.txt");
|
||||
Path notWatchedFile = tempDir.resolve("not-watched.txt");
|
||||
Files.createFile(watchedFile);
|
||||
Files.createFile(notWatchedFile);
|
||||
WaitingCallback callback = new WaitingCallback();
|
||||
this.fileWatcher.watch(Set.of(watchedFile), callback);
|
||||
Files.writeString(notWatchedFile, "Some content");
|
||||
callback.expectNoChanges();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailIfDirectoryOrFileDoesntExist(@TempDir Path tempDir) {
|
||||
Path directory = tempDir.resolve("dir1");
|
||||
assertThatThrownBy(() -> this.fileWatcher.watch(Set.of(directory), new WaitingCallback()))
|
||||
.isInstanceOf(UncheckedIOException.class)
|
||||
.hasMessageMatching("Failed to register paths for watching: \\[.+/dir1]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotFailIfDirectoryIsRegisteredMultipleTimes(@TempDir Path tempDir) {
|
||||
WaitingCallback callback = new WaitingCallback();
|
||||
assertThatCode(() -> {
|
||||
this.fileWatcher.watch(Set.of(tempDir), callback);
|
||||
this.fileWatcher.watch(Set.of(tempDir), callback);
|
||||
}).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotFailIfStoppedMultipleTimes(@TempDir Path tempDir) {
|
||||
WaitingCallback callback = new WaitingCallback();
|
||||
this.fileWatcher.watch(Set.of(tempDir), callback);
|
||||
assertThatCode(() -> {
|
||||
this.fileWatcher.stop();
|
||||
this.fileWatcher.stop();
|
||||
}).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
private void assertThatHasChanges(Set<Change> candidates, Change... changes) {
|
||||
assertThat(candidates).containsAll(Arrays.asList(changes));
|
||||
}
|
||||
|
||||
private static class WaitingCallback implements Callback {
|
||||
|
||||
private final CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
private final Set<Change> changes = new ConcurrentHashSet<>();
|
||||
|
||||
@Override
|
||||
public void onChange(Changes changes) {
|
||||
for (Change change : changes) {
|
||||
this.changes.add(change);
|
||||
}
|
||||
this.latch.countDown();
|
||||
}
|
||||
|
||||
Set<Change> waitForChanges() throws InterruptedException {
|
||||
if (!this.latch.await(10, TimeUnit.SECONDS)) {
|
||||
fail("Timeout while waiting for changes");
|
||||
}
|
||||
return this.changes;
|
||||
}
|
||||
|
||||
void expectNoChanges() throws InterruptedException {
|
||||
if (!this.latch.await(100, TimeUnit.MILLISECONDS)) {
|
||||
return;
|
||||
}
|
||||
assertThat(this.changes).isEmpty();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright 2012-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.boot.autoconfigure.ssl;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import org.springframework.boot.ssl.SslBundleRegistry;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.assertArg;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.BDDMockito.then;
|
||||
import static org.mockito.Mockito.times;
|
||||
|
||||
/**
|
||||
* Tests for {@link SslPropertiesBundleRegistrar}.
|
||||
*
|
||||
* @author Moritz Halbritter
|
||||
*/
|
||||
class SslPropertiesBundleRegistrarTests {
|
||||
|
||||
private SslPropertiesBundleRegistrar registrar;
|
||||
|
||||
private FileWatcher fileWatcher;
|
||||
|
||||
private SslProperties properties;
|
||||
|
||||
private SslBundleRegistry registry;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
this.properties = new SslProperties();
|
||||
this.fileWatcher = Mockito.mock(FileWatcher.class);
|
||||
this.registrar = new SslPropertiesBundleRegistrar(this.properties, this.fileWatcher);
|
||||
this.registry = Mockito.mock(SslBundleRegistry.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldWatchJksBundles() {
|
||||
JksSslBundleProperties jks = new JksSslBundleProperties();
|
||||
jks.setReloadOnUpdate(true);
|
||||
jks.getKeystore().setLocation("classpath:test.jks");
|
||||
jks.getKeystore().setPassword("secret");
|
||||
jks.getTruststore().setLocation("classpath:test.jks");
|
||||
jks.getTruststore().setPassword("secret");
|
||||
this.properties.getBundle().getJks().put("bundle1", jks);
|
||||
this.registrar.registerBundles(this.registry);
|
||||
then(this.registry).should(times(1)).registerBundle(eq("bundle1"), any());
|
||||
then(this.fileWatcher).should().watch(assertArg((set) -> pathEndingWith(set, "test.jks")), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldWatchPemBundles() {
|
||||
PemSslBundleProperties pem = new PemSslBundleProperties();
|
||||
pem.setReloadOnUpdate(true);
|
||||
pem.getKeystore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/rsa-cert.pem");
|
||||
pem.getKeystore().setPrivateKey("classpath:org/springframework/boot/autoconfigure/ssl/rsa-key.pem");
|
||||
pem.getTruststore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem");
|
||||
pem.getTruststore().setPrivateKey("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-key.pem");
|
||||
this.properties.getBundle().getPem().put("bundle1", pem);
|
||||
this.registrar.registerBundles(this.registry);
|
||||
then(this.registry).should(times(1)).registerBundle(eq("bundle1"), any());
|
||||
then(this.fileWatcher).should()
|
||||
.watch(assertArg((set) -> pathEndingWith(set, "rsa-cert.pem", "rsa-key.pem")), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailIfPemKeystoreCertificateIsEmbedded() {
|
||||
PemSslBundleProperties pem = new PemSslBundleProperties();
|
||||
pem.setReloadOnUpdate(true);
|
||||
pem.getKeystore().setCertificate("""
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICCzCCAb2gAwIBAgIUZbDi7G5czH+Yi0k2EMWxdf00XagwBQYDK2VwMHsxCzAJ
|
||||
BgNVBAYTAlhYMRIwEAYDVQQIDAlTdGF0ZU5hbWUxETAPBgNVBAcMCENpdHlOYW1l
|
||||
MRQwEgYDVQQKDAtDb21wYW55TmFtZTEbMBkGA1UECwwSQ29tcGFueVNlY3Rpb25O
|
||||
YW1lMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjMwOTExMTIxNDMwWhcNMzMwOTA4
|
||||
MTIxNDMwWjB7MQswCQYDVQQGEwJYWDESMBAGA1UECAwJU3RhdGVOYW1lMREwDwYD
|
||||
VQQHDAhDaXR5TmFtZTEUMBIGA1UECgwLQ29tcGFueU5hbWUxGzAZBgNVBAsMEkNv
|
||||
bXBhbnlTZWN0aW9uTmFtZTESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEA
|
||||
Q/DDA4BSgZ+Hx0DUxtIRjVjN+OcxXVURwAWc3Gt9GUyjUzBRMB0GA1UdDgQWBBSv
|
||||
EdpoaBMBoxgO96GFbf03k07DSTAfBgNVHSMEGDAWgBSvEdpoaBMBoxgO96GFbf03
|
||||
k07DSTAPBgNVHRMBAf8EBTADAQH/MAUGAytlcANBAHMXDkGd57d4F4cRk/8UjhxD
|
||||
7OtRBZfdfznSvlhJIMNfH5q0zbC2eO3hWCB3Hrn/vIeswGP8Ov4AJ6eXeX44BQM=
|
||||
-----END CERTIFICATE-----
|
||||
""".strip());
|
||||
this.properties.getBundle().getPem().put("bundle1", pem);
|
||||
assertThatIllegalStateException().isThrownBy(() -> this.registrar.registerBundles(this.registry))
|
||||
.withMessage("Keystore certificate is not a URL and can't be watched");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailIfPemKeystorePrivateKeyIsEmbedded() {
|
||||
PemSslBundleProperties pem = new PemSslBundleProperties();
|
||||
pem.setReloadOnUpdate(true);
|
||||
pem.getKeystore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem");
|
||||
pem.getKeystore().setPrivateKey("""
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIC29RnMVTcyqXEAIO1b/6p7RdbM6TiqvnztVQ4IxYxUh
|
||||
-----END PRIVATE KEY-----
|
||||
""".strip());
|
||||
this.properties.getBundle().getPem().put("bundle1", pem);
|
||||
assertThatIllegalStateException().isThrownBy(() -> this.registrar.registerBundles(this.registry))
|
||||
.withMessage("Keystore private key is not a URL and can't be watched");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailIfPemTruststoreCertificateIsEmbedded() {
|
||||
PemSslBundleProperties pem = new PemSslBundleProperties();
|
||||
pem.setReloadOnUpdate(true);
|
||||
pem.getTruststore().setCertificate("""
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICCzCCAb2gAwIBAgIUZbDi7G5czH+Yi0k2EMWxdf00XagwBQYDK2VwMHsxCzAJ
|
||||
BgNVBAYTAlhYMRIwEAYDVQQIDAlTdGF0ZU5hbWUxETAPBgNVBAcMCENpdHlOYW1l
|
||||
MRQwEgYDVQQKDAtDb21wYW55TmFtZTEbMBkGA1UECwwSQ29tcGFueVNlY3Rpb25O
|
||||
YW1lMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjMwOTExMTIxNDMwWhcNMzMwOTA4
|
||||
MTIxNDMwWjB7MQswCQYDVQQGEwJYWDESMBAGA1UECAwJU3RhdGVOYW1lMREwDwYD
|
||||
VQQHDAhDaXR5TmFtZTEUMBIGA1UECgwLQ29tcGFueU5hbWUxGzAZBgNVBAsMEkNv
|
||||
bXBhbnlTZWN0aW9uTmFtZTESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEA
|
||||
Q/DDA4BSgZ+Hx0DUxtIRjVjN+OcxXVURwAWc3Gt9GUyjUzBRMB0GA1UdDgQWBBSv
|
||||
EdpoaBMBoxgO96GFbf03k07DSTAfBgNVHSMEGDAWgBSvEdpoaBMBoxgO96GFbf03
|
||||
k07DSTAPBgNVHRMBAf8EBTADAQH/MAUGAytlcANBAHMXDkGd57d4F4cRk/8UjhxD
|
||||
7OtRBZfdfznSvlhJIMNfH5q0zbC2eO3hWCB3Hrn/vIeswGP8Ov4AJ6eXeX44BQM=
|
||||
-----END CERTIFICATE-----
|
||||
""".strip());
|
||||
this.properties.getBundle().getPem().put("bundle1", pem);
|
||||
assertThatIllegalStateException().isThrownBy(() -> this.registrar.registerBundles(this.registry))
|
||||
.withMessage("Truststore certificate is not a URL and can't be watched");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailIfPemTruststorePrivateKeyIsEmbedded() {
|
||||
PemSslBundleProperties pem = new PemSslBundleProperties();
|
||||
pem.setReloadOnUpdate(true);
|
||||
pem.getTruststore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem");
|
||||
pem.getTruststore().setPrivateKey("""
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIC29RnMVTcyqXEAIO1b/6p7RdbM6TiqvnztVQ4IxYxUh
|
||||
-----END PRIVATE KEY-----
|
||||
""".strip());
|
||||
this.properties.getBundle().getPem().put("bundle1", pem);
|
||||
assertThatIllegalStateException().isThrownBy(() -> this.registrar.registerBundles(this.registry))
|
||||
.withMessage("Truststore private key is not a URL and can't be watched");
|
||||
}
|
||||
|
||||
private void pathEndingWith(Set<Path> paths, String... suffixes) {
|
||||
for (String suffix : suffixes) {
|
||||
assertThat(paths).anyMatch((path) -> path.getFileName().toString().endsWith(suffix));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
/*
|
||||
* Copyright 2012-2023 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.boot.ssl.pem;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.Reader;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.util.FileCopyUtils;
|
||||
import org.springframework.util.ResourceUtils;
|
||||
|
||||
/**
|
||||
* Utility to load PEM content.
|
||||
*
|
||||
* @author Scott Frederick
|
||||
* @author Phillip Webb
|
||||
*/
|
||||
final class PemContent {
|
||||
|
||||
private static final Pattern PEM_HEADER = Pattern.compile("-+BEGIN\\s+[^-]*-+", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private static final Pattern PEM_FOOTER = Pattern.compile("-+END\\s+[^-]*-+", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private PemContent() {
|
||||
}
|
||||
|
||||
static String load(String content) {
|
||||
if (content == null || isPemContent(content)) {
|
||||
return content;
|
||||
}
|
||||
try {
|
||||
URL url = ResourceUtils.getURL(content);
|
||||
try (Reader reader = new InputStreamReader(url.openStream(), StandardCharsets.UTF_8)) {
|
||||
return FileCopyUtils.copyToString(reader);
|
||||
}
|
||||
}
|
||||
catch (IOException ex) {
|
||||
throw new IllegalStateException(
|
||||
"Error reading certificate or key from file '" + content + "':" + ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isPemContent(String content) {
|
||||
return content != null && PEM_HEADER.matcher(content).find() && PEM_FOOTER.matcher(content).find();
|
||||
}
|
||||
|
||||
}
|
@ -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