Skip to content

Commit 87c578c

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

5 files changed

Lines changed: 257 additions & 56 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: 47 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@
2020

2121
import io.netty.channel.group.ChannelGroup;
2222
import io.netty.handler.ssl.ClientAuth;
23-
import io.netty.handler.ssl.SslContext;
24-
import io.netty.handler.ssl.SslContextBuilder;
25-
import io.netty.handler.ssl.SslProvider;
2623
import io.netty.handler.timeout.IdleStateHandler;
24+
import nl.altindag.ssl.SSLFactory;
25+
import nl.altindag.ssl.exception.GenericSecurityException;
26+
import nl.altindag.ssl.netty.util.NettySslUtils;
27+
import nl.altindag.ssl.util.SSLFactoryUtils;
28+
import org.apache.tinkerpop.gremlin.server.util.SSLStoreFilesModificationWatcher;
2729
import org.apache.tinkerpop.gremlin.util.MessageSerializer;
2830
import org.apache.tinkerpop.gremlin.util.message.RequestMessage;
2931
import org.apache.tinkerpop.gremlin.util.message.ResponseMessage;
@@ -44,19 +46,12 @@
4446
import org.slf4j.Logger;
4547
import org.slf4j.LoggerFactory;
4648

47-
import javax.net.ssl.KeyManagerFactory;
48-
import javax.net.ssl.SSLException;
49-
import javax.net.ssl.TrustManagerFactory;
50-
5149
import java.io.FileInputStream;
5250
import java.io.IOException;
5351
import java.io.InputStream;
5452
import java.lang.reflect.Constructor;
5553
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;
54+
import java.sql.Time;
6055
import java.util.Arrays;
6156
import java.util.Collections;
6257
import java.util.HashMap;
@@ -65,6 +60,7 @@
6560
import java.util.Optional;
6661
import java.util.concurrent.ExecutorService;
6762
import java.util.concurrent.ScheduledExecutorService;
63+
import java.util.concurrent.TimeUnit;
6864
import java.util.stream.Stream;
6965

7066
/**
@@ -90,7 +86,7 @@ public abstract class AbstractChannelizer extends ChannelInitializer<SocketChann
9086

9187
protected Settings settings;
9288
protected GremlinExecutor gremlinExecutor;
93-
protected Optional<SslContext> sslContext;
89+
protected Optional<SSLFactory> sslFactory;
9490
protected GraphManager graphManager;
9591
protected ExecutorService gremlinExecutorService;
9692
protected ScheduledExecutorService scheduledExecutorService;
@@ -148,9 +144,25 @@ public void init(final ServerGremlinExecutor serverGremlinExecutor) {
148144
configureSerializers();
149145

150146
// configure ssl if present
151-
sslContext = settings.optionalSsl().isPresent() && settings.ssl.enabled ?
152-
Optional.ofNullable(createSSLContext(settings)) : Optional.empty();
153-
if (sslContext.isPresent()) logger.info("SSL enabled");
147+
sslFactory = settings.optionalSsl().isPresent() && settings.ssl.enabled ?
148+
Optional.ofNullable(createSSLFactoryBuilder(settings).withSwappableTrustMaterial().withSwappableIdentityMaterial().build()) : Optional.empty();
149+
150+
if (sslFactory.isPresent()) {
151+
logger.info("SSL enabled");
152+
// Every minute, check if keyStore/trustStore were modified, and if they were,
153+
// reload the SSLFactory which will reload the underlying KeyManager/TrustManager that Netty SSLHandler uses.
154+
scheduledExecutorService.schedule(
155+
new SSLStoreFilesModificationWatcher(settings.ssl.keyStore, settings.ssl.trustStore, () -> {
156+
SSLFactory newSslFactory = createSSLFactoryBuilder(settings).build();
157+
try {
158+
SSLFactoryUtils.reload(sslFactory.get(), newSslFactory);
159+
} catch (RuntimeException e) {
160+
logger.error("Failed to reload SSLFactory", e);
161+
}
162+
}),
163+
1, TimeUnit.MINUTES
164+
);
165+
}
154166

155167
authenticator = createAuthenticator(settings.authentication);
156168
authorizer = createAuthorizer(settings.authorization);
@@ -168,7 +180,9 @@ public void init(final ServerGremlinExecutor serverGremlinExecutor) {
168180
public void initChannel(final SocketChannel ch) throws Exception {
169181
final ChannelPipeline pipeline = ch.pipeline();
170182

171-
sslContext.ifPresent(sslContext -> pipeline.addLast(PIPELINE_SSL, sslContext.newHandler(ch.alloc())));
183+
if (sslFactory.isPresent()) {
184+
pipeline.addLast(PIPELINE_SSL, NettySslUtils.forServer(sslFactory.get()).build().newHandler(ch.alloc()));
185+
}
172186

173187
// checks for no activity on a channel and triggers an event that is consumed by the OpSelectorHandler
174188
// and either closes the connection or sends a ping to see if the client is still alive
@@ -307,77 +321,54 @@ private void configureSerializers() {
307321
}
308322
}
309323

310-
private SslContext createSSLContext(final Settings settings) {
324+
private SSLFactory.Builder createSSLFactoryBuilder(final Settings settings) {
311325
final Settings.SslSettings sslSettings = settings.ssl;
312326

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
327+
final SSLFactory.Builder builder = SSLFactory.builder();
323328
try {
324-
final KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
325-
326-
// Load private key and signed cert
327329
if (null != sslSettings.keyStore) {
328330
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();
331+
final char[] keyStorePassword = null == sslSettings.keyStorePassword ? null : sslSettings.keyStorePassword.toCharArray();
331332
try (final InputStream in = new FileInputStream(sslSettings.keyStore)) {
332-
keystore.load(in, password);
333+
builder.withIdentityMaterial(in, keyStorePassword, keyStoreType);
333334
}
334-
kmf.init(keystore, password);
335335
} else {
336336
throw new IllegalStateException("keyStore must be configured when SSL is enabled.");
337337
}
338338

339-
builder = SslContextBuilder.forServer(kmf);
340-
341339
// Load custom truststore for client auth certs
342340
if (null != sslSettings.trustStore) {
343341
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();
342+
: sslSettings.keyStoreType != null ? sslSettings.keyStoreType : KeyStore.getDefaultType();
343+
final char[] trustStorePassword = null == sslSettings.trustStorePassword ? null : sslSettings.trustStorePassword.toCharArray();
348344
try (final InputStream in = new FileInputStream(sslSettings.trustStore)) {
349-
truststore.load(in, password);
345+
builder.withTrustMaterial(in, trustStorePassword, trustStoreType);
350346
}
351-
final TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
352-
tmf.init(truststore);
353-
builder.trustManager(tmf);
354347
}
355-
356-
} catch (UnrecoverableKeyException | NoSuchAlgorithmException | KeyStoreException | CertificateException | IOException e) {
348+
} catch (GenericSecurityException | IOException e) {
357349
logger.error(e.getMessage());
358350
throw new RuntimeException("There was an error enabling SSL.", e);
359351
}
360352

361353
if (null != sslSettings.sslCipherSuites && !sslSettings.sslCipherSuites.isEmpty()) {
362-
builder.ciphers(sslSettings.sslCipherSuites);
354+
builder.withCiphers(sslSettings.sslCipherSuites.toArray(new String[] {}));
363355
}
364356

365357
if (null != sslSettings.sslEnabledProtocols && !sslSettings.sslEnabledProtocols.isEmpty()) {
366-
builder.protocols(sslSettings.sslEnabledProtocols.toArray(new String[] {}));
358+
builder.withProtocols(sslSettings.sslEnabledProtocols.toArray(new String[] {}));
367359
}
368-
360+
369361
if (null != sslSettings.needClientAuth && ClientAuth.OPTIONAL == sslSettings.needClientAuth) {
370362
logger.warn("needClientAuth = OPTIONAL is not a secure configuration. Setting to REQUIRE.");
371363
sslSettings.needClientAuth = ClientAuth.REQUIRE;
372364
}
373365

374-
builder.clientAuth(sslSettings.needClientAuth).sslProvider(provider);
375-
376-
try {
377-
return builder.build();
378-
} catch (SSLException ssle) {
379-
logger.error(ssle.getMessage());
380-
throw new RuntimeException("There was an error enabling SSL.", ssle);
366+
if (sslSettings.needClientAuth == ClientAuth.REQUIRE) {
367+
builder.withNeedClientAuthentication(true);
381368
}
369+
370+
// The SSL provider will default to SslProvider.OPEN_SSL if available or SslProvider.JDK if not (see SslContext#defaultProvider)
371+
372+
return builder;
382373
}
383374
}
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)