Skip to content

Commit c9e4595

Browse files
committed
Implement SSL hot reload
1 parent 7b1059a commit c9e4595

38 files changed

+1380
-139
lines changed

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@ org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
77
kotlinVersion=1.9.10
88
nativeBuildToolsVersion=0.9.27
99
springFrameworkVersion=6.1.0-SNAPSHOT
10-
tomcatVersion=10.1.13
10+
tomcatVersion=10.1.14
1111

1212
kotlin.stdlib.default.dependency=false
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
/*
2+
* Copyright 2012-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.autoconfigure.ssl;
18+
19+
import java.io.IOException;
20+
import java.io.UncheckedIOException;
21+
import java.nio.file.FileSystems;
22+
import java.nio.file.Files;
23+
import java.nio.file.Path;
24+
import java.nio.file.StandardWatchEventKinds;
25+
import java.nio.file.WatchEvent;
26+
import java.nio.file.WatchEvent.Kind;
27+
import java.nio.file.WatchKey;
28+
import java.nio.file.WatchService;
29+
import java.time.Duration;
30+
import java.util.ArrayList;
31+
import java.util.HashMap;
32+
import java.util.HashSet;
33+
import java.util.Iterator;
34+
import java.util.List;
35+
import java.util.Map;
36+
import java.util.Map.Entry;
37+
import java.util.Set;
38+
import java.util.concurrent.ConcurrentHashMap;
39+
import java.util.concurrent.CopyOnWriteArrayList;
40+
import java.util.concurrent.CountDownLatch;
41+
import java.util.concurrent.TimeUnit;
42+
43+
import org.apache.commons.logging.Log;
44+
import org.apache.commons.logging.LogFactory;
45+
46+
import org.springframework.core.log.LogMessage;
47+
import org.springframework.util.Assert;
48+
49+
/**
50+
* Watches files and directories and triggers a callback on change.
51+
*
52+
* @author Moritz Halbritter
53+
*/
54+
class FileWatcher implements AutoCloseable {
55+
56+
private static final Log logger = LogFactory.getLog(FileWatcher.class);
57+
58+
private final String threadName;
59+
60+
private final Duration quietPeriod;
61+
62+
private final Object lifecycleLock = new Object();
63+
64+
private final Map<WatchKey, List<Registration>> registrations = new ConcurrentHashMap<>();
65+
66+
private volatile WatchService watchService;
67+
68+
private Thread thread;
69+
70+
private boolean running = false;
71+
72+
FileWatcher(String threadName, Duration quietPeriod) {
73+
Assert.notNull(threadName, "threadName must not be null");
74+
Assert.notNull(quietPeriod, "quietPeriod must not be null");
75+
this.threadName = threadName;
76+
this.quietPeriod = quietPeriod;
77+
}
78+
79+
void watch(Set<Path> paths, Callback callback) {
80+
Assert.notNull(callback, "callback must not be null");
81+
Assert.notNull(paths, "paths must not be null");
82+
if (paths.isEmpty()) {
83+
return;
84+
}
85+
startIfNecessary();
86+
try {
87+
registerWatchables(callback, paths, this.watchService);
88+
}
89+
catch (IOException ex) {
90+
throw new UncheckedIOException("Failed to register paths for watching: " + paths, ex);
91+
}
92+
}
93+
94+
void stop() {
95+
synchronized (this.lifecycleLock) {
96+
if (!this.running) {
97+
return;
98+
}
99+
this.running = false;
100+
this.thread.interrupt();
101+
try {
102+
this.thread.join();
103+
}
104+
catch (InterruptedException ex) {
105+
Thread.currentThread().interrupt();
106+
}
107+
this.thread = null;
108+
this.watchService = null;
109+
this.registrations.clear();
110+
}
111+
}
112+
113+
private void startIfNecessary() {
114+
synchronized (this.lifecycleLock) {
115+
if (this.running) {
116+
return;
117+
}
118+
CountDownLatch started = new CountDownLatch(1);
119+
this.thread = new Thread(() -> this.threadMain(started));
120+
this.thread.setName(this.threadName);
121+
this.thread.setDaemon(true);
122+
this.thread.setUncaughtExceptionHandler(this::onThreadException);
123+
this.running = true;
124+
this.thread.start();
125+
try {
126+
started.await();
127+
}
128+
catch (InterruptedException ex) {
129+
Thread.currentThread().interrupt();
130+
}
131+
}
132+
}
133+
134+
private void threadMain(CountDownLatch started) {
135+
logger.debug("Watch thread started");
136+
try (WatchService watcher = FileSystems.getDefault().newWatchService()) {
137+
this.watchService = watcher;
138+
started.countDown();
139+
Map<Registration, List<Change>> accumulatedChanges = new HashMap<>();
140+
while (this.running) {
141+
try {
142+
WatchKey key = watcher.poll(this.quietPeriod.toMillis(), TimeUnit.MILLISECONDS);
143+
if (key == null) {
144+
// WatchService returned without any changes
145+
if (!accumulatedChanges.isEmpty()) {
146+
// We have queued changes, that means there were no changes
147+
// since the quiet period
148+
fireCallback(accumulatedChanges);
149+
accumulatedChanges.clear();
150+
}
151+
}
152+
else {
153+
accumulateChanges(key, accumulatedChanges);
154+
}
155+
}
156+
catch (InterruptedException ex) {
157+
Thread.currentThread().interrupt();
158+
}
159+
}
160+
logger.debug("Watch thread stopped");
161+
}
162+
catch (IOException ex) {
163+
throw new UncheckedIOException(ex);
164+
}
165+
}
166+
167+
private void accumulateChanges(WatchKey key, Map<Registration, List<Change>> accumulatedChanges)
168+
throws IOException {
169+
List<Registration> registrations = this.registrations.get(key);
170+
Path directory = (Path) key.watchable();
171+
for (WatchEvent<?> event : key.pollEvents()) {
172+
Path file = directory.resolve((Path) event.context());
173+
for (Registration registration : registrations) {
174+
if (registration.affectsFile(file)) {
175+
accumulatedChanges.computeIfAbsent(registration, (ignore) -> new ArrayList<>())
176+
.add(new Change(file, Type.from(event.kind())));
177+
}
178+
}
179+
}
180+
key.reset();
181+
}
182+
183+
private void fireCallback(Map<Registration, List<Change>> accumulatedChanges) {
184+
for (Entry<Registration, List<Change>> entry : accumulatedChanges.entrySet()) {
185+
Changes changes = new Changes(entry.getValue());
186+
if (!changes.isEmpty()) {
187+
entry.getKey().callback().onChange(changes);
188+
}
189+
}
190+
}
191+
192+
private void onThreadException(Thread thread, Throwable throwable) {
193+
logger.error("Uncaught exception in file watcher thread", throwable);
194+
}
195+
196+
private void registerWatchables(Callback callback, Set<Path> paths, WatchService watchService) throws IOException {
197+
Set<WatchKey> watchKeys = new HashSet<>();
198+
Set<Path> directories = new HashSet<>();
199+
Set<Path> files = new HashSet<>();
200+
for (Path path : paths) {
201+
Path realPath = path.toRealPath();
202+
if (Files.isDirectory(realPath)) {
203+
directories.add(realPath);
204+
watchKeys.add(registerDirectory(realPath, watchService));
205+
}
206+
else if (Files.isRegularFile(realPath)) {
207+
files.add(realPath);
208+
watchKeys.add(registerFile(realPath, watchService));
209+
}
210+
else {
211+
throw new IOException("'%s' is neither a file nor a directory".formatted(realPath));
212+
}
213+
}
214+
Registration registration = new Registration(callback, directories, files);
215+
for (WatchKey watchKey : watchKeys) {
216+
this.registrations.computeIfAbsent(watchKey, (ignore) -> new CopyOnWriteArrayList<>()).add(registration);
217+
}
218+
}
219+
220+
private WatchKey registerFile(Path file, WatchService watchService) throws IOException {
221+
return register(file.getParent(), watchService);
222+
}
223+
224+
private WatchKey registerDirectory(Path directory, WatchService watchService) throws IOException {
225+
return register(directory, watchService);
226+
}
227+
228+
private WatchKey register(Path directory, WatchService watchService) throws IOException {
229+
logger.debug(LogMessage.format("Registering '%s'", directory));
230+
return directory.register(watchService, StandardWatchEventKinds.ENTRY_CREATE,
231+
StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE);
232+
}
233+
234+
@Override
235+
public void close() {
236+
stop();
237+
}
238+
239+
private record Registration(Callback callback, Set<Path> directories, Set<Path> files) {
240+
boolean affectsFile(Path file) {
241+
return this.files.contains(file) || isInDirectories(file);
242+
}
243+
244+
private boolean isInDirectories(Path file) {
245+
for (Path directory : this.directories) {
246+
if (file.startsWith(directory)) {
247+
return true;
248+
}
249+
}
250+
return false;
251+
}
252+
}
253+
254+
enum Type {
255+
256+
CREATE, MODIFY, DELETE;
257+
258+
private static Type from(Kind<?> kind) {
259+
if (kind == StandardWatchEventKinds.ENTRY_CREATE) {
260+
return CREATE;
261+
}
262+
if (kind == StandardWatchEventKinds.ENTRY_DELETE) {
263+
return DELETE;
264+
}
265+
if (kind == StandardWatchEventKinds.ENTRY_MODIFY) {
266+
return MODIFY;
267+
}
268+
throw new IllegalArgumentException("Unknown kind: " + kind);
269+
}
270+
271+
}
272+
273+
record Change(Path path, Type type) {
274+
}
275+
276+
static class Changes implements Iterable<Change> {
277+
278+
private final List<Change> changes;
279+
280+
Changes(List<Change> changes) {
281+
this.changes = changes;
282+
}
283+
284+
@Override
285+
public Iterator<Change> iterator() {
286+
return this.changes.iterator();
287+
}
288+
289+
boolean isEmpty() {
290+
return this.changes.isEmpty();
291+
}
292+
293+
}
294+
295+
@FunctionalInterface
296+
interface Callback {
297+
298+
void onChange(Changes changes);
299+
300+
}
301+
302+
}

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/JksSslBundleProperties.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ public class JksSslBundleProperties extends SslBundleProperties {
3838
*/
3939
private final Store truststore = new Store();
4040

41+
/**
42+
* Whether to reload the SSL bundle.
43+
*/
44+
private boolean reloadOnUpdate;
45+
4146
public Store getKeystore() {
4247
return this.keystore;
4348
}
@@ -46,6 +51,14 @@ public Store getTruststore() {
4651
return this.truststore;
4752
}
4853

54+
public boolean isReloadOnUpdate() {
55+
return this.reloadOnUpdate;
56+
}
57+
58+
public void setReloadOnUpdate(boolean reloadOnUpdate) {
59+
this.reloadOnUpdate = reloadOnUpdate;
60+
}
61+
4962
/**
5063
* Store properties.
5164
*/

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PemSslBundleProperties.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.springframework.boot.autoconfigure.ssl;
1818

1919
import org.springframework.boot.ssl.pem.PemSslStoreBundle;
20+
import org.springframework.boot.ssl.pem.PemSslStoreDetails;
2021

2122
/**
2223
* {@link SslBundleProperties} for PEM-encoded certificates and private keys.
@@ -39,6 +40,11 @@ public class PemSslBundleProperties extends SslBundleProperties {
3940
*/
4041
private final Store truststore = new Store();
4142

43+
/**
44+
* Whether to reload the SSL bundle.
45+
*/
46+
private boolean reloadOnUpdate;
47+
4248
/**
4349
* Whether to verify that the private key matches the public key.
4450
*/
@@ -52,6 +58,14 @@ public Store getTruststore() {
5258
return this.truststore;
5359
}
5460

61+
public boolean isReloadOnUpdate() {
62+
return this.reloadOnUpdate;
63+
}
64+
65+
public void setReloadOnUpdate(boolean reloadOnUpdate) {
66+
this.reloadOnUpdate = reloadOnUpdate;
67+
}
68+
5569
public boolean isVerifyKeys() {
5670
return this.verifyKeys;
5771
}
@@ -117,6 +131,10 @@ public void setPrivateKeyPassword(String privateKeyPassword) {
117131
this.privateKeyPassword = privateKeyPassword;
118132
}
119133

134+
PemSslStoreDetails asPemSslStoreDetails() {
135+
return new PemSslStoreDetails(this.type, this.certificate, this.privateKey, this.privateKeyPassword);
136+
}
137+
120138
}
121139

122140
}

0 commit comments

Comments
 (0)