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