Skip to content

Commit a8f30ff

Browse files
committed
Support hot reloading of SSL certificates
1 parent cbafd31 commit a8f30ff

5 files changed

Lines changed: 260 additions & 45 deletions

File tree

gremlin-server/pom.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ limitations under the License.
5959
<artifactId>logback-classic</artifactId>
6060
<optional>true</optional>
6161
</dependency>
62+
<dependency>
63+
<groupId>io.github.hakky54</groupId>
64+
<artifactId>sslcontext-kickstart-for-netty</artifactId>
65+
<exclusions>
66+
<exclusion>
67+
<groupId>org.slf4j</groupId>
68+
<artifactId>slf4j-api</artifactId>
69+
</exclusion>
70+
</exclusions>
71+
</dependency>
6272
<!-- METRICS -->
6373
<dependency>
6474
<groupId>com.codahale.metrics</groupId>

gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/AbstractChannelizer.java

Lines changed: 50 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,13 @@
2121
import io.netty.channel.group.ChannelGroup;
2222
import io.netty.handler.ssl.ClientAuth;
2323
import io.netty.handler.ssl.SslContext;
24-
import io.netty.handler.ssl.SslContextBuilder;
2524
import io.netty.handler.ssl.SslProvider;
2625
import io.netty.handler.timeout.IdleStateHandler;
26+
import nl.altindag.ssl.SSLFactory;
27+
import nl.altindag.ssl.exception.GenericSecurityException;
28+
import nl.altindag.ssl.netty.util.NettySslUtils;
29+
import nl.altindag.ssl.util.SSLFactoryUtils;
30+
import org.apache.tinkerpop.gremlin.server.util.SSLStoreFilesModificationWatcher;
2731
import org.apache.tinkerpop.gremlin.util.MessageSerializer;
2832
import org.apache.tinkerpop.gremlin.util.message.RequestMessage;
2933
import org.apache.tinkerpop.gremlin.util.message.ResponseMessage;
@@ -44,19 +48,12 @@
4448
import org.slf4j.Logger;
4549
import org.slf4j.LoggerFactory;
4650

47-
import javax.net.ssl.KeyManagerFactory;
4851
import javax.net.ssl.SSLException;
49-
import javax.net.ssl.TrustManagerFactory;
50-
5152
import java.io.FileInputStream;
5253
import java.io.IOException;
5354
import java.io.InputStream;
5455
import java.lang.reflect.Constructor;
5556
import java.security.KeyStore;
56-
import java.security.KeyStoreException;
57-
import java.security.NoSuchAlgorithmException;
58-
import java.security.UnrecoverableKeyException;
59-
import java.security.cert.CertificateException;
6057
import java.util.Arrays;
6158
import java.util.Collections;
6259
import java.util.HashMap;
@@ -65,6 +62,7 @@
6562
import java.util.Optional;
6663
import java.util.concurrent.ExecutorService;
6764
import java.util.concurrent.ScheduledExecutorService;
65+
import java.util.concurrent.TimeUnit;
6866
import java.util.stream.Stream;
6967

7068
/**
@@ -148,8 +146,30 @@ public void init(final ServerGremlinExecutor serverGremlinExecutor) {
148146
configureSerializers();
149147

150148
// configure ssl if present
151-
sslContext = settings.optionalSsl().isPresent() && settings.ssl.enabled ?
152-
Optional.ofNullable(createSSLContext(settings)) : Optional.empty();
149+
if (settings.optionalSsl().isPresent() && settings.ssl.enabled) {
150+
if (settings.ssl.getSslContext().isPresent()) {
151+
logger.info("Using the SslContext override");
152+
this.sslContext = Optional.ofNullable(settings.ssl.getSslContext().get());
153+
} else {
154+
final SSLFactory sslFactory = createSSLFactoryBuilder(settings).withSwappableTrustMaterial().withSwappableIdentityMaterial().build();
155+
this.sslContext = Optional.of(createSSLContext(sslFactory));
156+
157+
// Every minute, check if keyStore/trustStore were modified, and if they were,
158+
// reload the SSLFactory which will reload the underlying KeyManager/TrustManager that Netty SSLHandler uses.
159+
scheduledExecutorService.scheduleAtFixedRate(
160+
new SSLStoreFilesModificationWatcher(settings.ssl.keyStore, settings.ssl.trustStore, () -> {
161+
SSLFactory newSslFactory = createSSLFactoryBuilder(settings).build();
162+
try {
163+
SSLFactoryUtils.reload(sslFactory, newSslFactory);
164+
} catch (RuntimeException e) {
165+
logger.error("Failed to reload SSLFactory", e);
166+
}
167+
}),
168+
1L, 1L, TimeUnit.MINUTES
169+
);
170+
}
171+
}
172+
153173
if (sslContext.isPresent()) logger.info("SSL enabled");
154174

155175
authenticator = createAuthenticator(settings.authentication);
@@ -307,74 +327,59 @@ private void configureSerializers() {
307327
}
308328
}
309329

310-
private SslContext createSSLContext(final Settings settings) {
330+
private SSLFactory.Builder createSSLFactoryBuilder(final Settings settings) {
311331
final Settings.SslSettings sslSettings = settings.ssl;
312332

313-
if (sslSettings.getSslContext().isPresent()) {
314-
logger.info("Using the SslContext override");
315-
return sslSettings.getSslContext().get();
316-
}
317-
318-
final SslProvider provider = SslProvider.JDK;
319-
320-
final SslContextBuilder builder;
321-
322-
// Build JSSE SSLContext
333+
final SSLFactory.Builder builder = SSLFactory.builder();
323334
try {
324-
final KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
325-
326-
// Load private key and signed cert
327335
if (null != sslSettings.keyStore) {
328336
final String keyStoreType = null == sslSettings.keyStoreType ? KeyStore.getDefaultType() : sslSettings.keyStoreType;
329-
final KeyStore keystore = KeyStore.getInstance(keyStoreType);
330-
final char[] password = null == sslSettings.keyStorePassword ? null : sslSettings.keyStorePassword.toCharArray();
337+
final char[] keyStorePassword = null == sslSettings.keyStorePassword ? null : sslSettings.keyStorePassword.toCharArray();
331338
try (final InputStream in = new FileInputStream(sslSettings.keyStore)) {
332-
keystore.load(in, password);
339+
builder.withIdentityMaterial(in, keyStorePassword, keyStoreType);
333340
}
334-
kmf.init(keystore, password);
335341
} else {
336342
throw new IllegalStateException("keyStore must be configured when SSL is enabled.");
337343
}
338344

339-
builder = SslContextBuilder.forServer(kmf);
340-
341345
// Load custom truststore for client auth certs
342346
if (null != sslSettings.trustStore) {
343347
final String trustStoreType = null != sslSettings.trustStoreType ? sslSettings.trustStoreType
344-
: sslSettings.keyStoreType != null ? sslSettings.keyStoreType : KeyStore.getDefaultType();
345-
346-
final KeyStore truststore = KeyStore.getInstance(trustStoreType);
347-
final char[] password = null == sslSettings.trustStorePassword ? null : sslSettings.trustStorePassword.toCharArray();
348+
: sslSettings.keyStoreType != null ? sslSettings.keyStoreType : KeyStore.getDefaultType();
349+
final char[] trustStorePassword = null == sslSettings.trustStorePassword ? null : sslSettings.trustStorePassword.toCharArray();
348350
try (final InputStream in = new FileInputStream(sslSettings.trustStore)) {
349-
truststore.load(in, password);
351+
builder.withTrustMaterial(in, trustStorePassword, trustStoreType);
350352
}
351-
final TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
352-
tmf.init(truststore);
353-
builder.trustManager(tmf);
354353
}
355-
356-
} catch (UnrecoverableKeyException | NoSuchAlgorithmException | KeyStoreException | CertificateException | IOException e) {
354+
} catch (GenericSecurityException | IOException e) {
357355
logger.error(e.getMessage());
358356
throw new RuntimeException("There was an error enabling SSL.", e);
359357
}
360358

361359
if (null != sslSettings.sslCipherSuites && !sslSettings.sslCipherSuites.isEmpty()) {
362-
builder.ciphers(sslSettings.sslCipherSuites);
360+
builder.withCiphers(sslSettings.sslCipherSuites.toArray(new String[] {}));
363361
}
364362

365363
if (null != sslSettings.sslEnabledProtocols && !sslSettings.sslEnabledProtocols.isEmpty()) {
366-
builder.protocols(sslSettings.sslEnabledProtocols.toArray(new String[] {}));
364+
builder.withProtocols(sslSettings.sslEnabledProtocols.toArray(new String[] {}));
367365
}
368-
366+
369367
if (null != sslSettings.needClientAuth && ClientAuth.OPTIONAL == sslSettings.needClientAuth) {
370368
logger.warn("needClientAuth = OPTIONAL is not a secure configuration. Setting to REQUIRE.");
371369
sslSettings.needClientAuth = ClientAuth.REQUIRE;
372370
}
373371

374-
builder.clientAuth(sslSettings.needClientAuth).sslProvider(provider);
372+
if (sslSettings.needClientAuth == ClientAuth.REQUIRE) {
373+
builder.withNeedClientAuthentication(true);
374+
}
375+
376+
return builder;
377+
}
375378

379+
private static SslContext createSSLContext(final SSLFactory sslFactory) {
376380
try {
377-
return builder.build();
381+
final SslProvider provider = SslProvider.JDK;
382+
return NettySslUtils.forServer(sslFactory).sslProvider(provider).build();
378383
} catch (SSLException ssle) {
379384
logger.error(ssle.getMessage());
380385
throw new RuntimeException("There was an error enabling SSL.", ssle);
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.tinkerpop.gremlin.server.util;
20+
21+
import org.slf4j.Logger;
22+
import org.slf4j.LoggerFactory;
23+
24+
import java.io.IOException;
25+
import java.io.UncheckedIOException;
26+
import java.nio.file.Files;
27+
import java.nio.file.Path;
28+
import java.nio.file.Paths;
29+
import java.nio.file.attribute.BasicFileAttributes;
30+
import java.time.ZoneOffset;
31+
import java.time.ZonedDateTime;
32+
33+
/**
34+
* FileWatcher monitoring changes to SSL keyStore/trustStore files.
35+
* If a keyStore/trustStore file is set to null, it will be ignored.
36+
* If a keyStore/trustStore file is deleted, it will be considered not modified.
37+
*/
38+
public class SSLStoreFilesModificationWatcher implements Runnable {
39+
40+
private static final Logger logger = LoggerFactory.getLogger(SSLStoreFilesModificationWatcher.class);
41+
42+
private final Path keyStore;
43+
private final Path trustStore;
44+
private final Runnable onModificationRunnable;
45+
46+
private ZonedDateTime lastModifiedTimeKeyStore = null;
47+
private ZonedDateTime lastModifiedTimeTrustStore = null;
48+
49+
/**
50+
* Create a FileWatcher on keyStore/trustStore
51+
*
52+
* @param keyStore path to the keyStore file or null to ignore
53+
* @param trustStore path to the trustStore file or null to ignore
54+
* @param onModificationRunnable function to run when a modification to the keyStore or trustStore is detected
55+
*/
56+
public SSLStoreFilesModificationWatcher(String keyStore, String trustStore, Runnable onModificationRunnable) {
57+
// keyStore/trustStore can be null when not specified in gremlin-server Settings
58+
this.keyStore = keyStore != null ? Paths.get(keyStore) : null;
59+
this.trustStore = trustStore != null ? Paths.get(trustStore) : null;
60+
this.onModificationRunnable = onModificationRunnable;
61+
62+
// Initialize lastModifiedTime
63+
try {
64+
if (this.keyStore != null) {
65+
lastModifiedTimeKeyStore = getLastModifiedTime(this.keyStore);
66+
}
67+
if (this.trustStore != null) {
68+
lastModifiedTimeTrustStore = getLastModifiedTime(this.trustStore);
69+
}
70+
} catch (IOException e) {
71+
throw new UncheckedIOException(e);
72+
}
73+
logger.info("Started listening to modifications to the KeyStore and TrustStore files");
74+
}
75+
76+
@Override
77+
public void run() {
78+
try {
79+
boolean keyStoreUpdated = false;
80+
boolean trustStoreUpdated = false;
81+
ZonedDateTime keyStoreModificationDateTime = null;
82+
ZonedDateTime trustStoreModificationDateTime = null;
83+
84+
// Check if the keyStore file still exists and compare its last_modified_time
85+
if (keyStore != null && Files.exists(keyStore)) {
86+
keyStoreModificationDateTime = getLastModifiedTime(keyStore);
87+
keyStoreUpdated = lastModifiedTimeKeyStore.isBefore(keyStoreModificationDateTime);
88+
if (keyStoreUpdated) {
89+
logger.info("KeyStore file has been modified.");
90+
}
91+
}
92+
93+
// Check if the trustStore file still exists and compare its last_modified_time
94+
if (trustStore != null && Files.exists(trustStore)) {
95+
trustStoreModificationDateTime = getLastModifiedTime(trustStore);
96+
trustStoreUpdated = lastModifiedTimeTrustStore.isBefore(trustStoreModificationDateTime);
97+
if (trustStoreUpdated) {
98+
logger.info("TrustStore file has been modified.");
99+
}
100+
}
101+
102+
// If one of the files was updated, execute
103+
if (keyStoreUpdated || trustStoreUpdated) {
104+
onModificationRunnable.run();
105+
106+
if (keyStoreUpdated) {
107+
lastModifiedTimeKeyStore = keyStoreModificationDateTime;
108+
logger.info("Updated KeyStore configuration");
109+
}
110+
if (trustStoreUpdated) {
111+
lastModifiedTimeTrustStore = trustStoreModificationDateTime;
112+
logger.info("Updated TrustStore configuration");
113+
}
114+
}
115+
} catch (IOException e) {
116+
throw new UncheckedIOException(e);
117+
}
118+
}
119+
120+
private static ZonedDateTime getLastModifiedTime(Path filepath) throws IOException {
121+
BasicFileAttributes attributes = Files.readAttributes(filepath, BasicFileAttributes.class);
122+
return ZonedDateTime.ofInstant(attributes.lastModifiedTime().toInstant(), ZoneOffset.UTC);
123+
}
124+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.tinkerpop.gremlin.server.util;
20+
21+
import io.cucumber.messages.internal.com.google.common.io.Files;
22+
import org.apache.tinkerpop.gremlin.TestHelper;
23+
import org.junit.Test;
24+
25+
import java.io.File;
26+
import java.io.IOException;
27+
import java.util.concurrent.atomic.AtomicBoolean;
28+
29+
import static org.junit.Assert.assertFalse;
30+
import static org.junit.Assert.assertTrue;
31+
32+
public class SSLStoreFilesModificationWatcherTest {
33+
@Test
34+
public void shouldDetectFileChange() throws IOException {
35+
File keyStoreFile = TestHelper.generateTempFileFromResource(SSLStoreFilesModificationWatcherTest.class, "/server-key.jks", "");
36+
File trustStoreFile = TestHelper.generateTempFileFromResource(SSLStoreFilesModificationWatcherTest.class, "/server-trust.jks", "");
37+
38+
AtomicBoolean modified = new AtomicBoolean(false);
39+
SSLStoreFilesModificationWatcher watcher = new SSLStoreFilesModificationWatcher(keyStoreFile.getAbsolutePath(), trustStoreFile.getAbsolutePath(), () -> modified.set(true));
40+
41+
// No modification yet
42+
watcher.run();
43+
assertFalse(modified.get());
44+
45+
// KeyStore file modified
46+
Files.touch(keyStoreFile);
47+
watcher.run();
48+
assertTrue(modified.get());
49+
modified.set(false);
50+
51+
// No modification
52+
watcher.run();
53+
assertFalse(modified.get());
54+
55+
// TrustStore file modified
56+
Files.touch(trustStoreFile);
57+
watcher.run();
58+
assertTrue(modified.get());
59+
modified.set(false);
60+
61+
// No modification
62+
watcher.run();
63+
assertFalse(modified.get());
64+
}
65+
}

0 commit comments

Comments
 (0)