Skip to content

Commit 19fd88b

Browse files
committed
Implement SSL hot reload for Netty and Tomcat
Closes gh-37808
1 parent 6f5688a commit 19fd88b

File tree

33 files changed

+1285
-104
lines changed

33 files changed

+1285
-104
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
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.Closeable;
20+
import java.io.IOException;
21+
import java.io.UncheckedIOException;
22+
import java.nio.file.ClosedWatchServiceException;
23+
import java.nio.file.FileSystems;
24+
import java.nio.file.Files;
25+
import java.nio.file.Path;
26+
import java.nio.file.StandardWatchEventKinds;
27+
import java.nio.file.WatchEvent;
28+
import java.nio.file.WatchKey;
29+
import java.nio.file.WatchService;
30+
import java.time.Duration;
31+
import java.util.HashSet;
32+
import java.util.List;
33+
import java.util.Map;
34+
import java.util.Set;
35+
import java.util.concurrent.ConcurrentHashMap;
36+
import java.util.concurrent.CopyOnWriteArrayList;
37+
import java.util.concurrent.TimeUnit;
38+
import java.util.stream.Collectors;
39+
40+
import org.apache.commons.logging.Log;
41+
import org.apache.commons.logging.LogFactory;
42+
43+
import org.springframework.core.log.LogMessage;
44+
import org.springframework.util.Assert;
45+
46+
/**
47+
* Watches files and directories and triggers a callback on change.
48+
*
49+
* @author Moritz Halbritter
50+
* @author Phillip Webb
51+
*/
52+
class FileWatcher implements Closeable {
53+
54+
private static final Log logger = LogFactory.getLog(FileWatcher.class);
55+
56+
private final Duration quietPeriod;
57+
58+
private final Object lock = new Object();
59+
60+
private WatcherThread thread;
61+
62+
/**
63+
* Create a new {@link FileWatcher} instance.
64+
* @param quietPeriod the duration that no file changes should occur before triggering
65+
* actions
66+
*/
67+
FileWatcher(Duration quietPeriod) {
68+
Assert.notNull(quietPeriod, "QuietPeriod must not be null");
69+
this.quietPeriod = quietPeriod;
70+
}
71+
72+
/**
73+
* Watch the given files or directories for changes.
74+
* @param paths the files or directories to watch
75+
* @param action the action to take when changes are detected
76+
*/
77+
void watch(Set<Path> paths, Runnable action) {
78+
Assert.notNull(paths, "Paths must not be null");
79+
Assert.notNull(action, "Action must not be null");
80+
if (paths.isEmpty()) {
81+
return;
82+
}
83+
synchronized (this.lock) {
84+
try {
85+
if (this.thread == null) {
86+
this.thread = new WatcherThread();
87+
this.thread.start();
88+
}
89+
this.thread.register(new Registration(paths, action));
90+
}
91+
catch (IOException ex) {
92+
throw new UncheckedIOException("Failed to register paths for watching: " + paths, ex);
93+
}
94+
}
95+
}
96+
97+
@Override
98+
public void close() throws IOException {
99+
synchronized (this.lock) {
100+
if (this.thread != null) {
101+
this.thread.close();
102+
this.thread.interrupt();
103+
try {
104+
this.thread.join();
105+
}
106+
catch (InterruptedException ex) {
107+
Thread.currentThread().interrupt();
108+
}
109+
this.thread = null;
110+
}
111+
}
112+
}
113+
114+
/**
115+
* The watcher thread used to check for changes.
116+
*/
117+
private class WatcherThread extends Thread implements Closeable {
118+
119+
private final WatchService watchService = FileSystems.getDefault().newWatchService();
120+
121+
private final Map<WatchKey, List<Registration>> registrations = new ConcurrentHashMap<>();
122+
123+
private volatile boolean running = true;
124+
125+
WatcherThread() throws IOException {
126+
setName("ssl-bundle-watcher");
127+
setDaemon(true);
128+
setUncaughtExceptionHandler(this::onThreadException);
129+
}
130+
131+
private void onThreadException(Thread thread, Throwable throwable) {
132+
logger.error("Uncaught exception in file watcher thread", throwable);
133+
}
134+
135+
void register(Registration registration) throws IOException {
136+
for (Path path : registration.paths()) {
137+
if (!Files.isRegularFile(path) && !Files.isDirectory(path)) {
138+
throw new IOException("'%s' is neither a file nor a directory".formatted(path));
139+
}
140+
Path directory = Files.isDirectory(path) ? path : path.getParent();
141+
WatchKey watchKey = register(directory);
142+
this.registrations.computeIfAbsent(watchKey, (key) -> new CopyOnWriteArrayList<>()).add(registration);
143+
}
144+
}
145+
146+
private WatchKey register(Path directory) throws IOException {
147+
logger.debug(LogMessage.format("Registering '%s'", directory));
148+
return directory.register(this.watchService, StandardWatchEventKinds.ENTRY_CREATE,
149+
StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE);
150+
}
151+
152+
@Override
153+
public void run() {
154+
logger.debug("Watch thread started");
155+
Set<Runnable> actions = new HashSet<>();
156+
while (this.running) {
157+
try {
158+
long timeout = FileWatcher.this.quietPeriod.toMillis();
159+
WatchKey key = this.watchService.poll(timeout, TimeUnit.MILLISECONDS);
160+
if (key == null) {
161+
actions.forEach(this::runSafely);
162+
actions.clear();
163+
}
164+
else {
165+
accumulate(key, actions);
166+
key.reset();
167+
}
168+
}
169+
catch (InterruptedException ex) {
170+
Thread.currentThread().interrupt();
171+
}
172+
catch (ClosedWatchServiceException ex) {
173+
logger.debug("File watcher has been closed");
174+
this.running = false;
175+
}
176+
}
177+
logger.debug("Watch thread stopped");
178+
}
179+
180+
private void runSafely(Runnable action) {
181+
try {
182+
action.run();
183+
}
184+
catch (Throwable ex) {
185+
logger.error("Unexpected SSL reload error", ex);
186+
}
187+
}
188+
189+
private void accumulate(WatchKey key, Set<Runnable> actions) {
190+
List<Registration> registrations = this.registrations.get(key);
191+
Path directory = (Path) key.watchable();
192+
for (WatchEvent<?> event : key.pollEvents()) {
193+
Path file = directory.resolve((Path) event.context());
194+
for (Registration registration : registrations) {
195+
if (registration.manages(file)) {
196+
actions.add(registration.action());
197+
}
198+
}
199+
}
200+
}
201+
202+
@Override
203+
public void close() throws IOException {
204+
this.running = false;
205+
this.watchService.close();
206+
}
207+
208+
}
209+
210+
/**
211+
* An individual watch registration.
212+
*/
213+
private record Registration(Set<Path> paths, Runnable action) {
214+
215+
Registration {
216+
paths = paths.stream().map(Path::toAbsolutePath).collect(Collectors.toSet());
217+
}
218+
219+
boolean manages(Path file) {
220+
Path absolutePath = file.toAbsolutePath();
221+
return this.paths.contains(absolutePath) || isInDirectories(absolutePath);
222+
}
223+
224+
private boolean isInDirectories(Path file) {
225+
return this.paths.stream().filter(Files::isDirectory).anyMatch(file::startsWith);
226+
}
227+
}
228+
229+
}

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

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@
1616

1717
package org.springframework.boot.autoconfigure.ssl;
1818

19-
import java.util.List;
20-
19+
import org.springframework.beans.factory.ObjectProvider;
2120
import org.springframework.boot.autoconfigure.AutoConfiguration;
2221
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
2322
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@@ -37,19 +36,27 @@
3736
@EnableConfigurationProperties(SslProperties.class)
3837
public class SslAutoConfiguration {
3938

40-
SslAutoConfiguration() {
39+
private final SslProperties sslProperties;
40+
41+
SslAutoConfiguration(SslProperties sslProperties) {
42+
this.sslProperties = sslProperties;
43+
}
44+
45+
@Bean
46+
FileWatcher fileWatcher() {
47+
return new FileWatcher(this.sslProperties.getBundle().getWatch().getFile().getQuietPeriod());
4148
}
4249

4350
@Bean
44-
public SslPropertiesBundleRegistrar sslPropertiesSslBundleRegistrar(SslProperties sslProperties) {
45-
return new SslPropertiesBundleRegistrar(sslProperties);
51+
SslPropertiesBundleRegistrar sslPropertiesSslBundleRegistrar(FileWatcher fileWatcher) {
52+
return new SslPropertiesBundleRegistrar(this.sslProperties, fileWatcher);
4653
}
4754

4855
@Bean
4956
@ConditionalOnMissingBean({ SslBundleRegistry.class, SslBundles.class })
50-
public DefaultSslBundleRegistry sslBundleRegistry(List<SslBundleRegistrar> sslBundleRegistrars) {
57+
DefaultSslBundleRegistry sslBundleRegistry(ObjectProvider<SslBundleRegistrar> sslBundleRegistrars) {
5158
DefaultSslBundleRegistry registry = new DefaultSslBundleRegistry();
52-
sslBundleRegistrars.forEach((registrar) -> registrar.registerBundles(registry));
59+
sslBundleRegistrars.orderedStream().forEach((registrar) -> registrar.registerBundles(registry));
5360
return registry;
5461
}
5562

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public abstract class SslBundleProperties {
3636
private final Key key = new Key();
3737

3838
/**
39-
* Options for the SLL connection.
39+
* Options for the SSL connection.
4040
*/
4141
private final Options options = new Options();
4242

@@ -45,6 +45,11 @@ public abstract class SslBundleProperties {
4545
*/
4646
private String protocol = SslBundle.DEFAULT_PROTOCOL;
4747

48+
/**
49+
* Whether to reload the SSL bundle.
50+
*/
51+
private boolean reloadOnUpdate;
52+
4853
public Key getKey() {
4954
return this.key;
5055
}
@@ -61,6 +66,14 @@ public void setProtocol(String protocol) {
6166
this.protocol = protocol;
6267
}
6368

69+
public boolean isReloadOnUpdate() {
70+
return this.reloadOnUpdate;
71+
}
72+
73+
public void setReloadOnUpdate(boolean reloadOnUpdate) {
74+
this.reloadOnUpdate = reloadOnUpdate;
75+
}
76+
6477
public static class Options {
6578

6679
/**

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package org.springframework.boot.autoconfigure.ssl;
1818

19+
import java.time.Duration;
1920
import java.util.LinkedHashMap;
2021
import java.util.Map;
2122

@@ -25,6 +26,7 @@
2526
* Properties for centralized SSL trust material configuration.
2627
*
2728
* @author Scott Frederick
29+
* @author Moritz Halbritter
2830
* @since 3.1.0
2931
*/
3032
@ConfigurationProperties(prefix = "spring.ssl")
@@ -54,6 +56,11 @@ public static class Bundles {
5456
*/
5557
private final Map<String, JksSslBundleProperties> jks = new LinkedHashMap<>();
5658

59+
/**
60+
* Trust material watching.
61+
*/
62+
private final Watch watch = new Watch();
63+
5764
public Map<String, PemSslBundleProperties> getPem() {
5865
return this.pem;
5966
}
@@ -62,6 +69,40 @@ public Map<String, JksSslBundleProperties> getJks() {
6269
return this.jks;
6370
}
6471

72+
public Watch getWatch() {
73+
return this.watch;
74+
}
75+
76+
public static class Watch {
77+
78+
/**
79+
* File watching.
80+
*/
81+
private final File file = new File();
82+
83+
public File getFile() {
84+
return this.file;
85+
}
86+
87+
public static class File {
88+
89+
/**
90+
* Quiet period, after which changes are detected.
91+
*/
92+
private Duration quietPeriod = Duration.ofSeconds(10);
93+
94+
public Duration getQuietPeriod() {
95+
return this.quietPeriod;
96+
}
97+
98+
public void setQuietPeriod(Duration quietPeriod) {
99+
this.quietPeriod = quietPeriod;
100+
}
101+
102+
}
103+
104+
}
105+
65106
}
66107

67108
}

0 commit comments

Comments
 (0)