Skip to content

Commit bd388e9

Browse files
nickbabcockjplock
authored andcommitted
Reload certificate configuration at runtime (dropwizard#1799)
* Reload certificate configuration at runtime * Add documentation on SslReloadBundle * Update release notes for runtime certificate reload
1 parent c48bc04 commit bd388e9

File tree

15 files changed

+353
-8
lines changed

15 files changed

+353
-8
lines changed

.codeclimate.yml

+1
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ exclude_paths:
1717
- "**.ts"
1818
- "**.p12"
1919
- "**.jts"
20+
- "**.jks"
2021
- "**.keystore"

docs/source/about/release-notes.rst

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Release Notes
99
v1.1.0: Unreleased
1010
==================
1111

12+
* Add runtime certificate reload via admin task `#1799 <https://github.com/dropwizard/dropwizard/pull/1799>`_
1213
* Invalid enum request parameters result in 400 response with possible choices `#1734 <https://github.com/dropwizard/dropwizard/pull/1734>`_
1314
* Enum request parameters are deserialized in the same fuzzy manner, as the request body `#1734 <https://github.com/dropwizard/dropwizard/pull/1734>`_
1415
* Request parameter name displayed in response to parse failure `#1734 <https://github.com/dropwizard/dropwizard/pull/1734>`_

docs/source/manual/core.rst

+34
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,40 @@ instances, the extended constructor should be used to specify a unique name for
525525
bootstrap.addBundle(new AssetsBundle("/assets/fonts", "/fonts", null, "fonts"));
526526
}
527527
528+
.. _man-core-bundles-ssl-reload:
529+
530+
SSL Reload
531+
----------
532+
533+
By registering the ``SslReloadBundle`` your application can have new certificate information
534+
reloaded at runtime, so a restart is not necessary.
535+
536+
.. code-block:: java
537+
538+
@Override
539+
public void initialize(Bootstrap<HelloWorldConfiguration> bootstrap) {
540+
bootstrap.addBundle(new SslReloadBundle());
541+
}
542+
543+
To trigger a reload send a ``POST`` request to ``ssl-reload``
544+
545+
.. code-block::
546+
547+
curl -k -X POST 'https://localhost:<admin-port>/tasks/ssl-reload'
548+
549+
Dropwizard will use the same exact https configuration (keystore location, password, etc) when
550+
performing the reload.
551+
552+
.. note::
553+
554+
If anything is wrong with the new certificate (eg. wrong password in keystore), no new
555+
certificates are loaded. So if the application and admin ports use different certificates and
556+
one of them is invalid, then none of them are reloaded.
557+
558+
A http 500 error is returned on reload failure, so make sure to trap for this error with
559+
whatever tool is used to trigger a certificate reload, and alert the appropriate admin. If the
560+
situation is not remedied, next time the app is stopped, it will be unable to start!
561+
528562
.. _man-core-commands:
529563

530564
Commands
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package io.dropwizard.sslreload;
2+
3+
import com.google.common.collect.ImmutableSet;
4+
import io.dropwizard.Bundle;
5+
import io.dropwizard.jetty.MutableServletContextHandler;
6+
import io.dropwizard.jetty.SslReload;
7+
import io.dropwizard.setup.Bootstrap;
8+
import io.dropwizard.setup.Environment;
9+
import org.eclipse.jetty.util.component.AbstractLifeCycle;
10+
import org.eclipse.jetty.util.component.LifeCycle;
11+
import org.slf4j.Logger;
12+
import org.slf4j.LoggerFactory;
13+
14+
import java.util.Collection;
15+
16+
/** Bundle that gathers all the ssl connectors and registers an admin task that will
17+
* refresh ssl configuration on request */
18+
public class SslReloadBundle implements Bundle {
19+
private static final Logger LOGGER = LoggerFactory.getLogger(SslReloadBundle.class);
20+
21+
private final SslReloadTask reloadTask = new SslReloadTask();
22+
23+
@Override
24+
public void initialize(Bootstrap<?> bootstrap) {
25+
}
26+
27+
@Override
28+
public void run(Environment environment) {
29+
environment.getApplicationContext().addLifeCycleListener(new AbstractLifeCycle.AbstractLifeCycleListener() {
30+
@Override
31+
public void lifeCycleStarted(LifeCycle event) {
32+
final ImmutableSet<SslReload> reloaders = ImmutableSet.<SslReload>builder()
33+
.addAll(getReloaders(environment.getApplicationContext()))
34+
.addAll(getReloaders(environment.getAdminContext()))
35+
.build();
36+
37+
LOGGER.info("{} ssl reloaders registered", reloaders.size());
38+
reloadTask.setReloaders(reloaders);
39+
}
40+
});
41+
42+
environment.admin().addTask(reloadTask);
43+
}
44+
45+
private Collection<SslReload> getReloaders(MutableServletContextHandler handler) {
46+
return handler.getServer().getBeans(SslReload.class);
47+
}
48+
}
49+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package io.dropwizard.sslreload;
2+
3+
import com.google.common.collect.ImmutableMultimap;
4+
import io.dropwizard.jetty.SslReload;
5+
import io.dropwizard.servlets.tasks.Task;
6+
import org.eclipse.jetty.util.ssl.SslContextFactory;
7+
8+
import java.io.PrintWriter;
9+
import java.util.Collection;
10+
11+
/** A task that will refresh all ssl factories with up to date certificate information */
12+
public class SslReloadTask extends Task {
13+
private Collection<SslReload> reloader;
14+
15+
protected SslReloadTask() {
16+
super("reload-ssl");
17+
}
18+
19+
@Override
20+
public void execute(ImmutableMultimap<String, String> parameters, PrintWriter output) throws Exception {
21+
// Iterate through all the reloaders first to ensure valid configuration
22+
for (SslReload reloader : getReloaders()) {
23+
reloader.reload(new SslContextFactory());
24+
}
25+
26+
// Now we know that configuration is valid, reload for real
27+
for (SslReload reloader : getReloaders()) {
28+
reloader.reload();
29+
}
30+
31+
output.write("Reloaded certificate configuration\n");
32+
}
33+
34+
public Collection<SslReload> getReloaders() {
35+
return reloader;
36+
}
37+
38+
public void setReloaders(Collection<SslReload> reloader) {
39+
this.reloader = reloader;
40+
}
41+
}
42+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.example.sslreload;
2+
3+
import io.dropwizard.Application;
4+
import io.dropwizard.Configuration;
5+
import io.dropwizard.setup.Bootstrap;
6+
import io.dropwizard.setup.Environment;
7+
import io.dropwizard.sslreload.SslReloadBundle;
8+
9+
public class SslReloadApp extends Application<Configuration> {
10+
@Override
11+
public void initialize(Bootstrap<Configuration> bootstrap) {
12+
bootstrap.addBundle(new SslReloadBundle());
13+
}
14+
15+
@Override
16+
public void run(Configuration configuration, Environment environment) throws Exception {
17+
}
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package com.example.sslreload;
2+
3+
import com.google.common.io.CharStreams;
4+
import com.google.common.io.Files;
5+
import com.google.common.io.Resources;
6+
import io.dropwizard.Configuration;
7+
import io.dropwizard.testing.ConfigOverride;
8+
import io.dropwizard.testing.ResourceHelpers;
9+
import io.dropwizard.testing.junit.DropwizardAppRule;
10+
import org.apache.http.conn.ssl.NoopHostnameVerifier;
11+
import org.junit.After;
12+
import org.junit.BeforeClass;
13+
import org.junit.ClassRule;
14+
import org.junit.Rule;
15+
import org.junit.Test;
16+
import org.junit.rules.TemporaryFolder;
17+
18+
import javax.net.ssl.HttpsURLConnection;
19+
import javax.net.ssl.SSLContext;
20+
import javax.net.ssl.TrustManager;
21+
import javax.net.ssl.X509TrustManager;
22+
import java.io.File;
23+
import java.io.IOException;
24+
import java.io.InputStreamReader;
25+
import java.net.URL;
26+
import java.nio.charset.StandardCharsets;
27+
import java.security.cert.CertificateException;
28+
import java.security.cert.X509Certificate;
29+
30+
import static org.assertj.core.api.Java6Assertions.assertThat;
31+
32+
public class SslReloadAppTest {
33+
@ClassRule
34+
public static final TemporaryFolder FOLDER = new TemporaryFolder();
35+
36+
private static final X509TrustManager TRUST_ALL = new X509TrustManager() {
37+
@Override
38+
public void checkClientTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {
39+
40+
}
41+
42+
@Override
43+
public void checkServerTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {
44+
45+
}
46+
47+
@Override
48+
public X509Certificate[] getAcceptedIssuers() {
49+
return new X509Certificate[0];
50+
}
51+
};
52+
53+
private static File keystore;
54+
55+
@Rule
56+
public final DropwizardAppRule<Configuration> rule =
57+
new DropwizardAppRule<>(SslReloadApp.class, ResourceHelpers.resourceFilePath("sslreload/config.yml"),
58+
ConfigOverride.config("server.applicationConnectors[0].keyStorePath", keystore.getAbsolutePath()),
59+
ConfigOverride.config("server.adminConnectors[0].keyStorePath", keystore.getAbsolutePath()));
60+
61+
@BeforeClass
62+
public static void setupClass() throws IOException {
63+
keystore = FOLDER.newFile("keystore.jks");
64+
final byte[] keystoreBytes = Resources.toByteArray(Resources.getResource("sslreload/keystore.jks"));
65+
Files.write(keystoreBytes, keystore);
66+
}
67+
68+
@After
69+
public void after() throws IOException {
70+
// Reset keystore to known good keystore
71+
final byte[] keystoreBytes = Resources.toByteArray(Resources.getResource("sslreload/keystore.jks"));
72+
Files.write(keystoreBytes, keystore);
73+
}
74+
75+
@Test
76+
public void reloadCertificateChangesTheServerCertificate() throws Exception {
77+
// Copy over our new keystore that has our new certificate to the current
78+
// location of our keystore
79+
final byte[] keystore2Bytes = Resources.toByteArray(Resources.getResource("sslreload/keystore2.jks"));
80+
Files.write(keystore2Bytes, keystore);
81+
82+
// Get the bytes for the first certificate, and trigger a reload
83+
byte[] firstCertBytes = certBytes(200, "Reloaded certificate configuration\n");
84+
85+
// Get the bytes from our newly reloaded certificate
86+
byte[] secondCertBytes = certBytes(200, "Reloaded certificate configuration\n");
87+
88+
// Get the bytes from the reloaded certificate, but it should be the same
89+
// as the second cert because we didn't change anything!
90+
byte[] thirdCertBytes = certBytes(200, "Reloaded certificate configuration\n");
91+
92+
assertThat(firstCertBytes).isNotEqualTo(secondCertBytes);
93+
assertThat(secondCertBytes).isEqualTo(thirdCertBytes);
94+
}
95+
96+
@Test
97+
public void badReloadDoesNotChangeTheServerCertificate() throws Exception {
98+
// This keystore has a different password than what jetty has been configured with
99+
// the password is "password2"
100+
final byte[] badKeystore = Resources.toByteArray(Resources.getResource("sslreload/keystore-diff-pwd.jks"));
101+
Files.write(badKeystore, keystore);
102+
103+
// Get the bytes for the first certificate. The reload should fail
104+
byte[] firstCertBytes = certBytes(500, "Keystore was tampered with, or password was incorrect");
105+
106+
// Issue another request. The returned certificate should be the same as before
107+
byte[] secondCertBytes = certBytes(500, "Keystore was tampered with, or password was incorrect");
108+
109+
// And just to triple check, a third request will continue with
110+
// the same original certificate
111+
byte[] thirdCertBytes = certBytes(500, "Keystore was tampered with, or password was incorrect");
112+
113+
assertThat(firstCertBytes)
114+
.isEqualTo(secondCertBytes)
115+
.isEqualTo(thirdCertBytes);
116+
}
117+
118+
/** Issues a POST against the reload ssl admin task, asserts that the code and content
119+
* are as expected, and finally returns the server certificate */
120+
private byte[] certBytes(int code, String content) throws Exception {
121+
final URL url = new URL("https://localhost:" + rule.getAdminPort() + "/tasks/reload-ssl");
122+
final HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
123+
try {
124+
postIt(conn);
125+
126+
assertThat(conn.getResponseCode()).isEqualTo(code);
127+
if (code == 200) {
128+
assertThat(CharStreams.toString(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8)))
129+
.isEqualTo(content);
130+
} else {
131+
assertThat(CharStreams.toString(new InputStreamReader(conn.getErrorStream(), StandardCharsets.UTF_8)))
132+
.contains(content);
133+
}
134+
135+
// The certificates are self signed, so are the only cert in the chain.
136+
// Thus, we return the one and only certificate.
137+
return conn.getServerCertificates()[0].getEncoded();
138+
} finally {
139+
conn.disconnect();
140+
}
141+
}
142+
143+
/** Configure SSL and POST request parameters */
144+
private void postIt(HttpsURLConnection conn) throws Exception {
145+
final SSLContext sslCtx = SSLContext.getInstance("TLS");
146+
sslCtx.init(null, new TrustManager[]{TRUST_ALL}, null);
147+
148+
conn.setHostnameVerifier(new NoopHostnameVerifier());
149+
conn.setSSLSocketFactory(sslCtx.getSocketFactory());
150+
151+
// Make it a POST
152+
conn.setDoOutput(true);
153+
conn.getOutputStream().write(new byte[]{});
154+
}
155+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
server:
2+
applicationConnectors:
3+
- type: https
4+
port: 0
5+
keyStorePath: keystore.jks
6+
keyStorePassword: password
7+
validateCerts: false
8+
validatePeers: false
9+
adminConnectors:
10+
- type: https
11+
port: 0
12+
keyStorePath: keystore.jks
13+
keyStorePassword: password
14+
validateCerts: false
15+
validatePeers: false
Binary file not shown.
Binary file not shown.
Binary file not shown.

dropwizard-http2/src/main/java/io/dropwizard/http2/Http2ConnectorFactory.java

+3-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import com.google.common.collect.ImmutableList;
77
import io.dropwizard.jetty.HttpsConnectorFactory;
88
import io.dropwizard.jetty.Jetty93InstrumentedConnectionFactory;
9+
import io.dropwizard.jetty.SslReload;
910
import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory;
1011
import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory;
1112
import org.eclipse.jetty.server.Connector;
@@ -108,9 +109,10 @@ public Connector build(Server server, MetricRegistry metrics, String name, Threa
108109
final NegotiatingServerConnectionFactory alpn = new ALPNServerConnectionFactory(H2, H2_17);
109110
alpn.setDefaultProtocol(HTTP_1_1); // Speak HTTP 1.1 over TLS if negotiation fails
110111

111-
final SslContextFactory sslContextFactory = buildSslContextFactory();
112+
final SslContextFactory sslContextFactory = configureSslContextFactory(new SslContextFactory());
112113
sslContextFactory.addLifeCycleListener(logSslInfoOnStart(sslContextFactory));
113114
server.addBean(sslContextFactory);
115+
server.addBean(new SslReload(sslContextFactory, this::configureSslContextFactory));
114116

115117
// We should use ALPN as a negotiation protocol. Old clients that don't support it will be served
116118
// via HTTPS. New clients, however, that want to use HTTP/2 will use TLS with ALPN extension.

dropwizard-jetty/src/main/java/io/dropwizard/jetty/HttpsConnectorFactory.java

+3-3
Original file line numberDiff line numberDiff line change
@@ -533,10 +533,11 @@ public Connector build(Server server, MetricRegistry metrics, String name, Threa
533533

534534
final HttpConnectionFactory httpConnectionFactory = buildHttpConnectionFactory(httpConfig);
535535

536-
final SslContextFactory sslContextFactory = buildSslContextFactory();
536+
final SslContextFactory sslContextFactory = configureSslContextFactory(new SslContextFactory());
537537
sslContextFactory.addLifeCycleListener(logSslInfoOnStart(sslContextFactory));
538538

539539
server.addBean(sslContextFactory);
540+
server.addBean(new SslReload(sslContextFactory, this::configureSslContextFactory));
540541

541542
final SslConnectionFactory sslConnectionFactory =
542543
new SslConnectionFactory(sslContextFactory, HttpVersion.HTTP_1_1.toString());
@@ -599,8 +600,7 @@ private void logSupportedParameters(SSLContext context) {
599600
}
600601
}
601602

602-
protected SslContextFactory buildSslContextFactory() {
603-
final SslContextFactory factory = new SslContextFactory();
603+
protected SslContextFactory configureSslContextFactory(SslContextFactory factory) {
604604
if (keyStorePath != null) {
605605
factory.setKeyStorePath(keyStorePath);
606606
}

0 commit comments

Comments
 (0)