Skip to content

Commit

Permalink
Reload certificate configuration at runtime (#1799)
Browse files Browse the repository at this point in the history
* Reload certificate configuration at runtime

* Add documentation on SslReloadBundle

* Update release notes for runtime certificate reload
  • Loading branch information
nickbabcock authored and jplock committed Nov 7, 2016
1 parent c48bc04 commit bd388e9
Show file tree
Hide file tree
Showing 15 changed files with 353 additions and 8 deletions.
1 change: 1 addition & 0 deletions .codeclimate.yml
Expand Up @@ -17,4 +17,5 @@ exclude_paths:
- "**.ts" - "**.ts"
- "**.p12" - "**.p12"
- "**.jts" - "**.jts"
- "**.jks"
- "**.keystore" - "**.keystore"
1 change: 1 addition & 0 deletions docs/source/about/release-notes.rst
Expand Up @@ -9,6 +9,7 @@ Release Notes
v1.1.0: Unreleased v1.1.0: Unreleased
================== ==================


* Add runtime certificate reload via admin task `#1799 <https://github.com/dropwizard/dropwizard/pull/1799>`_
* Invalid enum request parameters result in 400 response with possible choices `#1734 <https://github.com/dropwizard/dropwizard/pull/1734>`_ * Invalid enum request parameters result in 400 response with possible choices `#1734 <https://github.com/dropwizard/dropwizard/pull/1734>`_
* Enum request parameters are deserialized in the same fuzzy manner, as the request body `#1734 <https://github.com/dropwizard/dropwizard/pull/1734>`_ * Enum request parameters are deserialized in the same fuzzy manner, as the request body `#1734 <https://github.com/dropwizard/dropwizard/pull/1734>`_
* Request parameter name displayed in response to parse failure `#1734 <https://github.com/dropwizard/dropwizard/pull/1734>`_ * Request parameter name displayed in response to parse failure `#1734 <https://github.com/dropwizard/dropwizard/pull/1734>`_
Expand Down
34 changes: 34 additions & 0 deletions docs/source/manual/core.rst
Expand Up @@ -525,6 +525,40 @@ instances, the extended constructor should be used to specify a unique name for
bootstrap.addBundle(new AssetsBundle("/assets/fonts", "/fonts", null, "fonts")); bootstrap.addBundle(new AssetsBundle("/assets/fonts", "/fonts", null, "fonts"));
} }
.. _man-core-bundles-ssl-reload:

SSL Reload
----------

By registering the ``SslReloadBundle`` your application can have new certificate information
reloaded at runtime, so a restart is not necessary.

.. code-block:: java
@Override
public void initialize(Bootstrap<HelloWorldConfiguration> bootstrap) {
bootstrap.addBundle(new SslReloadBundle());
}
To trigger a reload send a ``POST`` request to ``ssl-reload``

.. code-block::
curl -k -X POST 'https://localhost:<admin-port>/tasks/ssl-reload'
Dropwizard will use the same exact https configuration (keystore location, password, etc) when
performing the reload.

.. note::

If anything is wrong with the new certificate (eg. wrong password in keystore), no new
certificates are loaded. So if the application and admin ports use different certificates and
one of them is invalid, then none of them are reloaded.

A http 500 error is returned on reload failure, so make sure to trap for this error with
whatever tool is used to trigger a certificate reload, and alert the appropriate admin. If the
situation is not remedied, next time the app is stopped, it will be unable to start!

.. _man-core-commands: .. _man-core-commands:


Commands Commands
Expand Down
@@ -0,0 +1,49 @@
package io.dropwizard.sslreload;

import com.google.common.collect.ImmutableSet;
import io.dropwizard.Bundle;
import io.dropwizard.jetty.MutableServletContextHandler;
import io.dropwizard.jetty.SslReload;
import io.dropwizard.setup.Bootstrap;
import io.dropwizard.setup.Environment;
import org.eclipse.jetty.util.component.AbstractLifeCycle;
import org.eclipse.jetty.util.component.LifeCycle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Collection;

/** Bundle that gathers all the ssl connectors and registers an admin task that will
* refresh ssl configuration on request */
public class SslReloadBundle implements Bundle {
private static final Logger LOGGER = LoggerFactory.getLogger(SslReloadBundle.class);

private final SslReloadTask reloadTask = new SslReloadTask();

@Override
public void initialize(Bootstrap<?> bootstrap) {
}

@Override
public void run(Environment environment) {
environment.getApplicationContext().addLifeCycleListener(new AbstractLifeCycle.AbstractLifeCycleListener() {
@Override
public void lifeCycleStarted(LifeCycle event) {
final ImmutableSet<SslReload> reloaders = ImmutableSet.<SslReload>builder()
.addAll(getReloaders(environment.getApplicationContext()))
.addAll(getReloaders(environment.getAdminContext()))
.build();

LOGGER.info("{} ssl reloaders registered", reloaders.size());
reloadTask.setReloaders(reloaders);
}
});

environment.admin().addTask(reloadTask);
}

private Collection<SslReload> getReloaders(MutableServletContextHandler handler) {
return handler.getServer().getBeans(SslReload.class);
}
}

@@ -0,0 +1,42 @@
package io.dropwizard.sslreload;

import com.google.common.collect.ImmutableMultimap;
import io.dropwizard.jetty.SslReload;
import io.dropwizard.servlets.tasks.Task;
import org.eclipse.jetty.util.ssl.SslContextFactory;

import java.io.PrintWriter;
import java.util.Collection;

/** A task that will refresh all ssl factories with up to date certificate information */
public class SslReloadTask extends Task {
private Collection<SslReload> reloader;

protected SslReloadTask() {
super("reload-ssl");
}

@Override
public void execute(ImmutableMultimap<String, String> parameters, PrintWriter output) throws Exception {
// Iterate through all the reloaders first to ensure valid configuration
for (SslReload reloader : getReloaders()) {
reloader.reload(new SslContextFactory());
}

// Now we know that configuration is valid, reload for real
for (SslReload reloader : getReloaders()) {
reloader.reload();
}

output.write("Reloaded certificate configuration\n");
}

public Collection<SslReload> getReloaders() {
return reloader;
}

public void setReloaders(Collection<SslReload> reloader) {
this.reloader = reloader;
}
}

@@ -0,0 +1,18 @@
package com.example.sslreload;

import io.dropwizard.Application;
import io.dropwizard.Configuration;
import io.dropwizard.setup.Bootstrap;
import io.dropwizard.setup.Environment;
import io.dropwizard.sslreload.SslReloadBundle;

public class SslReloadApp extends Application<Configuration> {
@Override
public void initialize(Bootstrap<Configuration> bootstrap) {
bootstrap.addBundle(new SslReloadBundle());
}

@Override
public void run(Configuration configuration, Environment environment) throws Exception {
}
}
@@ -0,0 +1,155 @@
package com.example.sslreload;

import com.google.common.io.CharStreams;
import com.google.common.io.Files;
import com.google.common.io.Resources;
import io.dropwizard.Configuration;
import io.dropwizard.testing.ConfigOverride;
import io.dropwizard.testing.ResourceHelpers;
import io.dropwizard.testing.junit.DropwizardAppRule;
import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.junit.After;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;

import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;

import static org.assertj.core.api.Java6Assertions.assertThat;

public class SslReloadAppTest {
@ClassRule
public static final TemporaryFolder FOLDER = new TemporaryFolder();

private static final X509TrustManager TRUST_ALL = new X509TrustManager() {
@Override
public void checkClientTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {

}

@Override
public void checkServerTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {

}

@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
};

private static File keystore;

@Rule
public final DropwizardAppRule<Configuration> rule =
new DropwizardAppRule<>(SslReloadApp.class, ResourceHelpers.resourceFilePath("sslreload/config.yml"),
ConfigOverride.config("server.applicationConnectors[0].keyStorePath", keystore.getAbsolutePath()),
ConfigOverride.config("server.adminConnectors[0].keyStorePath", keystore.getAbsolutePath()));

@BeforeClass
public static void setupClass() throws IOException {
keystore = FOLDER.newFile("keystore.jks");
final byte[] keystoreBytes = Resources.toByteArray(Resources.getResource("sslreload/keystore.jks"));
Files.write(keystoreBytes, keystore);
}

@After
public void after() throws IOException {
// Reset keystore to known good keystore
final byte[] keystoreBytes = Resources.toByteArray(Resources.getResource("sslreload/keystore.jks"));
Files.write(keystoreBytes, keystore);
}

@Test
public void reloadCertificateChangesTheServerCertificate() throws Exception {
// Copy over our new keystore that has our new certificate to the current
// location of our keystore
final byte[] keystore2Bytes = Resources.toByteArray(Resources.getResource("sslreload/keystore2.jks"));
Files.write(keystore2Bytes, keystore);

// Get the bytes for the first certificate, and trigger a reload
byte[] firstCertBytes = certBytes(200, "Reloaded certificate configuration\n");

// Get the bytes from our newly reloaded certificate
byte[] secondCertBytes = certBytes(200, "Reloaded certificate configuration\n");

// Get the bytes from the reloaded certificate, but it should be the same
// as the second cert because we didn't change anything!
byte[] thirdCertBytes = certBytes(200, "Reloaded certificate configuration\n");

assertThat(firstCertBytes).isNotEqualTo(secondCertBytes);
assertThat(secondCertBytes).isEqualTo(thirdCertBytes);
}

@Test
public void badReloadDoesNotChangeTheServerCertificate() throws Exception {
// This keystore has a different password than what jetty has been configured with
// the password is "password2"
final byte[] badKeystore = Resources.toByteArray(Resources.getResource("sslreload/keystore-diff-pwd.jks"));
Files.write(badKeystore, keystore);

// Get the bytes for the first certificate. The reload should fail
byte[] firstCertBytes = certBytes(500, "Keystore was tampered with, or password was incorrect");

// Issue another request. The returned certificate should be the same as before
byte[] secondCertBytes = certBytes(500, "Keystore was tampered with, or password was incorrect");

// And just to triple check, a third request will continue with
// the same original certificate
byte[] thirdCertBytes = certBytes(500, "Keystore was tampered with, or password was incorrect");

assertThat(firstCertBytes)
.isEqualTo(secondCertBytes)
.isEqualTo(thirdCertBytes);
}

/** Issues a POST against the reload ssl admin task, asserts that the code and content
* are as expected, and finally returns the server certificate */
private byte[] certBytes(int code, String content) throws Exception {
final URL url = new URL("https://localhost:" + rule.getAdminPort() + "/tasks/reload-ssl");
final HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
try {
postIt(conn);

assertThat(conn.getResponseCode()).isEqualTo(code);
if (code == 200) {
assertThat(CharStreams.toString(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8)))
.isEqualTo(content);
} else {
assertThat(CharStreams.toString(new InputStreamReader(conn.getErrorStream(), StandardCharsets.UTF_8)))
.contains(content);
}

// The certificates are self signed, so are the only cert in the chain.
// Thus, we return the one and only certificate.
return conn.getServerCertificates()[0].getEncoded();
} finally {
conn.disconnect();
}
}

/** Configure SSL and POST request parameters */
private void postIt(HttpsURLConnection conn) throws Exception {
final SSLContext sslCtx = SSLContext.getInstance("TLS");
sslCtx.init(null, new TrustManager[]{TRUST_ALL}, null);

conn.setHostnameVerifier(new NoopHostnameVerifier());
conn.setSSLSocketFactory(sslCtx.getSocketFactory());

// Make it a POST
conn.setDoOutput(true);
conn.getOutputStream().write(new byte[]{});
}
}
15 changes: 15 additions & 0 deletions dropwizard-e2e/src/test/resources/sslreload/config.yml
@@ -0,0 +1,15 @@
server:
applicationConnectors:
- type: https
port: 0
keyStorePath: keystore.jks
keyStorePassword: password
validateCerts: false
validatePeers: false
adminConnectors:
- type: https
port: 0
keyStorePath: keystore.jks
keyStorePassword: password
validateCerts: false
validatePeers: false
Binary file not shown.
Binary file not shown.
Binary file not shown.
Expand Up @@ -6,6 +6,7 @@
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import io.dropwizard.jetty.HttpsConnectorFactory; import io.dropwizard.jetty.HttpsConnectorFactory;
import io.dropwizard.jetty.Jetty93InstrumentedConnectionFactory; import io.dropwizard.jetty.Jetty93InstrumentedConnectionFactory;
import io.dropwizard.jetty.SslReload;
import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory; import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory;
import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory; import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory;
import org.eclipse.jetty.server.Connector; import org.eclipse.jetty.server.Connector;
Expand Down Expand Up @@ -108,9 +109,10 @@ public Connector build(Server server, MetricRegistry metrics, String name, Threa
final NegotiatingServerConnectionFactory alpn = new ALPNServerConnectionFactory(H2, H2_17); final NegotiatingServerConnectionFactory alpn = new ALPNServerConnectionFactory(H2, H2_17);
alpn.setDefaultProtocol(HTTP_1_1); // Speak HTTP 1.1 over TLS if negotiation fails alpn.setDefaultProtocol(HTTP_1_1); // Speak HTTP 1.1 over TLS if negotiation fails


final SslContextFactory sslContextFactory = buildSslContextFactory(); final SslContextFactory sslContextFactory = configureSslContextFactory(new SslContextFactory());
sslContextFactory.addLifeCycleListener(logSslInfoOnStart(sslContextFactory)); sslContextFactory.addLifeCycleListener(logSslInfoOnStart(sslContextFactory));
server.addBean(sslContextFactory); server.addBean(sslContextFactory);
server.addBean(new SslReload(sslContextFactory, this::configureSslContextFactory));


// We should use ALPN as a negotiation protocol. Old clients that don't support it will be served // We should use ALPN as a negotiation protocol. Old clients that don't support it will be served
// via HTTPS. New clients, however, that want to use HTTP/2 will use TLS with ALPN extension. // via HTTPS. New clients, however, that want to use HTTP/2 will use TLS with ALPN extension.
Expand Down
Expand Up @@ -533,10 +533,11 @@ public Connector build(Server server, MetricRegistry metrics, String name, Threa


final HttpConnectionFactory httpConnectionFactory = buildHttpConnectionFactory(httpConfig); final HttpConnectionFactory httpConnectionFactory = buildHttpConnectionFactory(httpConfig);


final SslContextFactory sslContextFactory = buildSslContextFactory(); final SslContextFactory sslContextFactory = configureSslContextFactory(new SslContextFactory());
sslContextFactory.addLifeCycleListener(logSslInfoOnStart(sslContextFactory)); sslContextFactory.addLifeCycleListener(logSslInfoOnStart(sslContextFactory));


server.addBean(sslContextFactory); server.addBean(sslContextFactory);
server.addBean(new SslReload(sslContextFactory, this::configureSslContextFactory));


final SslConnectionFactory sslConnectionFactory = final SslConnectionFactory sslConnectionFactory =
new SslConnectionFactory(sslContextFactory, HttpVersion.HTTP_1_1.toString()); new SslConnectionFactory(sslContextFactory, HttpVersion.HTTP_1_1.toString());
Expand Down Expand Up @@ -599,8 +600,7 @@ private void logSupportedParameters(SSLContext context) {
} }
} }


protected SslContextFactory buildSslContextFactory() { protected SslContextFactory configureSslContextFactory(SslContextFactory factory) {
final SslContextFactory factory = new SslContextFactory();
if (keyStorePath != null) { if (keyStorePath != null) {
factory.setKeyStorePath(keyStorePath); factory.setKeyStorePath(keyStorePath);
} }
Expand Down

0 comments on commit bd388e9

Please sign in to comment.