diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc
index 8e0d126c102..97f29200ff3 100644
--- a/CHANGELOG.asciidoc
+++ b/CHANGELOG.asciidoc
@@ -45,6 +45,7 @@ image::https://raw.githubusercontent.com/apache/tinkerpop/master/docs/static/ima
* Changed `gremlin-go` Client `ReadBufferSize` and `WriteBufferSize` defaults to 1048576 (1MB) to align with DriverRemoteConnection.
* Fixed bug in `IndexStep` which prevented Java serialization due to non-serializable lambda usage by creating serializable function classes.
* Fixed bug in `Operator` which was caused only a single method parameter to be Collection type checked instead of all parameters.
+* Support hot reloading of SSL certificates.
[[release-3-7-3]]
=== TinkerPop 3.7.3 (October 23, 2024)
diff --git a/gremlin-server/pom.xml b/gremlin-server/pom.xml
index 80fc2cb60ec..97ed90a1d73 100644
--- a/gremlin-server/pom.xml
+++ b/gremlin-server/pom.xml
@@ -59,6 +59,16 @@ limitations under the License.
logback-classic
true
+
+ io.github.hakky54
+ sslcontext-kickstart-for-netty
+
+
+ org.slf4j
+ slf4j-api
+
+
+
com.codahale.metrics
diff --git a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/AbstractChannelizer.java b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/AbstractChannelizer.java
index c133f686529..9af52699dc7 100644
--- a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/AbstractChannelizer.java
+++ b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/AbstractChannelizer.java
@@ -21,9 +21,13 @@
import io.netty.channel.group.ChannelGroup;
import io.netty.handler.ssl.ClientAuth;
import io.netty.handler.ssl.SslContext;
-import io.netty.handler.ssl.SslContextBuilder;
import io.netty.handler.ssl.SslProvider;
import io.netty.handler.timeout.IdleStateHandler;
+import nl.altindag.ssl.SSLFactory;
+import nl.altindag.ssl.exception.GenericSecurityException;
+import nl.altindag.ssl.netty.util.NettySslUtils;
+import nl.altindag.ssl.util.SSLFactoryUtils;
+import org.apache.tinkerpop.gremlin.server.util.SSLStoreFilesModificationWatcher;
import org.apache.tinkerpop.gremlin.util.MessageSerializer;
import org.apache.tinkerpop.gremlin.util.message.RequestMessage;
import org.apache.tinkerpop.gremlin.util.message.ResponseMessage;
@@ -44,19 +48,12 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLException;
-import javax.net.ssl.TrustManagerFactory;
-
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.security.KeyStore;
-import java.security.KeyStoreException;
-import java.security.NoSuchAlgorithmException;
-import java.security.UnrecoverableKeyException;
-import java.security.cert.CertificateException;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
@@ -65,6 +62,7 @@
import java.util.Optional;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
/**
@@ -148,8 +146,34 @@ public void init(final ServerGremlinExecutor serverGremlinExecutor) {
configureSerializers();
// configure ssl if present
- sslContext = settings.optionalSsl().isPresent() && settings.ssl.enabled ?
- Optional.ofNullable(createSSLContext(settings)) : Optional.empty();
+ if (settings.optionalSsl().isPresent() && settings.ssl.enabled) {
+ if (settings.ssl.getSslContext().isPresent()) {
+ logger.info("Using the SslContext override");
+ this.sslContext = settings.ssl.getSslContext();
+ } else {
+ final SSLFactory sslFactory = createSSLFactoryBuilder(settings).withSwappableTrustMaterial().withSwappableIdentityMaterial().build();
+ this.sslContext = Optional.of(createSSLContext(sslFactory));
+
+ if (settings.ssl.refreshInterval > 0) {
+ // At the scheduled refreshInterval, check whether the keyStore or trustStore has been modified. If they were,
+ // reload the SSLFactory which will reload the underlying KeyManager/TrustManager that Netty SSLHandler uses.
+ scheduledExecutorService.scheduleAtFixedRate(
+ new SSLStoreFilesModificationWatcher(settings.ssl.keyStore, settings.ssl.trustStore, () -> {
+ SSLFactory newSslFactory = createSSLFactoryBuilder(settings).build();
+ try {
+ SSLFactoryUtils.reload(sslFactory, newSslFactory);
+ } catch (RuntimeException e) {
+ logger.error("Failed to reload SSLFactory", e);
+ }
+ }),
+ settings.ssl.refreshInterval, settings.ssl.refreshInterval, TimeUnit.MILLISECONDS
+ );
+ }
+ }
+ } else {
+ this.sslContext = Optional.empty();
+ }
+
if (sslContext.isPresent()) logger.info("SSL enabled");
authenticator = createAuthenticator(settings.authentication);
@@ -307,74 +331,59 @@ private void configureSerializers() {
}
}
- private SslContext createSSLContext(final Settings settings) {
+ private SSLFactory.Builder createSSLFactoryBuilder(final Settings settings) {
final Settings.SslSettings sslSettings = settings.ssl;
- if (sslSettings.getSslContext().isPresent()) {
- logger.info("Using the SslContext override");
- return sslSettings.getSslContext().get();
- }
-
- final SslProvider provider = SslProvider.JDK;
-
- final SslContextBuilder builder;
-
- // Build JSSE SSLContext
+ final SSLFactory.Builder builder = SSLFactory.builder();
try {
- final KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
-
- // Load private key and signed cert
if (null != sslSettings.keyStore) {
final String keyStoreType = null == sslSettings.keyStoreType ? KeyStore.getDefaultType() : sslSettings.keyStoreType;
- final KeyStore keystore = KeyStore.getInstance(keyStoreType);
final char[] password = null == sslSettings.keyStorePassword ? null : sslSettings.keyStorePassword.toCharArray();
try (final InputStream in = new FileInputStream(sslSettings.keyStore)) {
- keystore.load(in, password);
+ builder.withIdentityMaterial(in, password, keyStoreType);
}
- kmf.init(keystore, password);
} else {
throw new IllegalStateException("keyStore must be configured when SSL is enabled.");
}
- builder = SslContextBuilder.forServer(kmf);
-
// Load custom truststore for client auth certs
if (null != sslSettings.trustStore) {
final String trustStoreType = null != sslSettings.trustStoreType ? sslSettings.trustStoreType
- : sslSettings.keyStoreType != null ? sslSettings.keyStoreType : KeyStore.getDefaultType();
-
- final KeyStore truststore = KeyStore.getInstance(trustStoreType);
+ : sslSettings.keyStoreType != null ? sslSettings.keyStoreType : KeyStore.getDefaultType();
final char[] password = null == sslSettings.trustStorePassword ? null : sslSettings.trustStorePassword.toCharArray();
try (final InputStream in = new FileInputStream(sslSettings.trustStore)) {
- truststore.load(in, password);
+ builder.withTrustMaterial(in, password, trustStoreType);
}
- final TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
- tmf.init(truststore);
- builder.trustManager(tmf);
}
-
- } catch (UnrecoverableKeyException | NoSuchAlgorithmException | KeyStoreException | CertificateException | IOException e) {
+ } catch (GenericSecurityException | IOException e) {
logger.error(e.getMessage());
throw new RuntimeException("There was an error enabling SSL.", e);
}
if (null != sslSettings.sslCipherSuites && !sslSettings.sslCipherSuites.isEmpty()) {
- builder.ciphers(sslSettings.sslCipherSuites);
+ builder.withCiphers(sslSettings.sslCipherSuites.toArray(new String[] {}));
}
if (null != sslSettings.sslEnabledProtocols && !sslSettings.sslEnabledProtocols.isEmpty()) {
- builder.protocols(sslSettings.sslEnabledProtocols.toArray(new String[] {}));
+ builder.withProtocols(sslSettings.sslEnabledProtocols.toArray(new String[] {}));
}
-
+
if (null != sslSettings.needClientAuth && ClientAuth.OPTIONAL == sslSettings.needClientAuth) {
logger.warn("needClientAuth = OPTIONAL is not a secure configuration. Setting to REQUIRE.");
sslSettings.needClientAuth = ClientAuth.REQUIRE;
}
- builder.clientAuth(sslSettings.needClientAuth).sslProvider(provider);
+ if (sslSettings.needClientAuth == ClientAuth.REQUIRE) {
+ builder.withNeedClientAuthentication(true);
+ }
+
+ return builder;
+ }
+ private static SslContext createSSLContext(final SSLFactory sslFactory) {
try {
- return builder.build();
+ final SslProvider provider = SslProvider.JDK;
+ return NettySslUtils.forServer(sslFactory).sslProvider(provider).build();
} catch (SSLException ssle) {
logger.error(ssle.getMessage());
throw new RuntimeException("There was an error enabling SSL.", ssle);
diff --git a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/Settings.java b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/Settings.java
index 7a21afcf0d1..8968fa2daa4 100644
--- a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/Settings.java
+++ b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/Settings.java
@@ -581,6 +581,12 @@ public static class SslSettings {
*/
public ClientAuth needClientAuth = ClientAuth.NONE;
+ /**
+ * The interval, in milliseconds, at which the trustStore and keyStore files are checked for updates.
+ * The default interval is 60 seconds.
+ */
+ public long refreshInterval = 60000L;
+
private SslContext sslContext;
/**
diff --git a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/util/SSLStoreFilesModificationWatcher.java b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/util/SSLStoreFilesModificationWatcher.java
new file mode 100644
index 00000000000..fc80e151a96
--- /dev/null
+++ b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/util/SSLStoreFilesModificationWatcher.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.server.util;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+
+/**
+ * FileWatcher monitoring changes to SSL keyStore/trustStore files.
+ * If a keyStore/trustStore file is set to null, it will be ignored.
+ * If a keyStore/trustStore file is deleted, it will be considered not modified.
+ */
+public class SSLStoreFilesModificationWatcher implements Runnable {
+
+ private static final Logger logger = LoggerFactory.getLogger(SSLStoreFilesModificationWatcher.class);
+
+ private final Path keyStore;
+ private final Path trustStore;
+ private final Runnable onModificationRunnable;
+
+ private ZonedDateTime lastModifiedTimeKeyStore = null;
+ private ZonedDateTime lastModifiedTimeTrustStore = null;
+
+ /**
+ * Create a FileWatcher on keyStore/trustStore
+ *
+ * @param keyStore path to the keyStore file or null to ignore
+ * @param trustStore path to the trustStore file or null to ignore
+ * @param onModificationRunnable function to run when a modification to the keyStore or trustStore is detected
+ */
+ public SSLStoreFilesModificationWatcher(final String keyStore, final String trustStore, final Runnable onModificationRunnable) {
+ // keyStore/trustStore can be null when not specified in gremlin-server Settings
+ this.keyStore = keyStore != null ? Paths.get(keyStore) : null;
+ this.trustStore = trustStore != null ? Paths.get(trustStore) : null;
+ this.onModificationRunnable = onModificationRunnable;
+
+ // Initialize lastModifiedTime
+ try {
+ if (this.keyStore != null) {
+ lastModifiedTimeKeyStore = getLastModifiedTime(this.keyStore);
+ }
+ if (this.trustStore != null) {
+ lastModifiedTimeTrustStore = getLastModifiedTime(this.trustStore);
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ logger.info("Started listening to modifications to the KeyStore and TrustStore files");
+ }
+
+ @Override
+ public void run() {
+ try {
+ boolean keyStoreUpdated = false;
+ boolean trustStoreUpdated = false;
+ ZonedDateTime keyStoreModificationDateTime = null;
+ ZonedDateTime trustStoreModificationDateTime = null;
+
+ // Check if the keyStore file still exists and compare its last_modified_time
+ if (keyStore != null && Files.exists(keyStore)) {
+ keyStoreModificationDateTime = getLastModifiedTime(keyStore);
+ keyStoreUpdated = lastModifiedTimeKeyStore.isBefore(keyStoreModificationDateTime);
+ if (keyStoreUpdated) {
+ logger.info("KeyStore file has been modified.");
+ }
+ }
+
+ // Check if the trustStore file still exists and compare its last_modified_time
+ if (trustStore != null && Files.exists(trustStore)) {
+ trustStoreModificationDateTime = getLastModifiedTime(trustStore);
+ trustStoreUpdated = lastModifiedTimeTrustStore.isBefore(trustStoreModificationDateTime);
+ if (trustStoreUpdated) {
+ logger.info("TrustStore file has been modified.");
+ }
+ }
+
+ // If one of the files was updated, execute
+ if (keyStoreUpdated || trustStoreUpdated) {
+ onModificationRunnable.run();
+
+ if (keyStoreUpdated) {
+ lastModifiedTimeKeyStore = keyStoreModificationDateTime;
+ logger.info("Updated KeyStore configuration");
+ }
+ if (trustStoreUpdated) {
+ lastModifiedTimeTrustStore = trustStoreModificationDateTime;
+ logger.info("Updated TrustStore configuration");
+ }
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ private static ZonedDateTime getLastModifiedTime(final Path filepath) throws IOException {
+ BasicFileAttributes attributes = Files.readAttributes(filepath, BasicFileAttributes.class);
+ return ZonedDateTime.ofInstant(attributes.lastModifiedTime().toInstant(), ZoneOffset.UTC);
+ }
+}
diff --git a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/util/SSLStoreFilesModificationWatcherTest.java b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/util/SSLStoreFilesModificationWatcherTest.java
new file mode 100644
index 00000000000..46fe502d4d9
--- /dev/null
+++ b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/util/SSLStoreFilesModificationWatcherTest.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.server.util;
+
+import io.cucumber.messages.internal.com.google.common.io.Files;
+import org.apache.tinkerpop.gremlin.TestHelper;
+import org.junit.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class SSLStoreFilesModificationWatcherTest {
+ @Test
+ public void shouldDetectFileChange() throws IOException {
+ File keyStoreFile = TestHelper.generateTempFileFromResource(SSLStoreFilesModificationWatcherTest.class, "/server-key.jks", "");
+ File trustStoreFile = TestHelper.generateTempFileFromResource(SSLStoreFilesModificationWatcherTest.class, "/server-trust.jks", "");
+
+ AtomicBoolean modified = new AtomicBoolean(false);
+ SSLStoreFilesModificationWatcher watcher = new SSLStoreFilesModificationWatcher(keyStoreFile.getAbsolutePath(), trustStoreFile.getAbsolutePath(), () -> modified.set(true));
+
+ // No modification yet
+ watcher.run();
+ assertFalse(modified.get());
+
+ // KeyStore file modified
+ Files.touch(keyStoreFile);
+ watcher.run();
+ assertTrue(modified.get());
+ modified.set(false);
+
+ // No modification
+ watcher.run();
+ assertFalse(modified.get());
+
+ // TrustStore file modified
+ Files.touch(trustStoreFile);
+ watcher.run();
+ assertTrue(modified.get());
+ modified.set(false);
+
+ // No modification
+ watcher.run();
+ assertFalse(modified.get());
+ }
+}
diff --git a/pom.xml b/pom.xml
index 02f4e96a793..50aad5a6e84 100644
--- a/pom.xml
+++ b/pom.xml
@@ -178,6 +178,7 @@ limitations under the License.
1.7.25
2.0
3.3.2
+ 9.1.0
UTF-8
UTF-8
@@ -781,6 +782,11 @@ limitations under the License.
commons-lang3
${commons.lang3.version}
+
+ io.github.hakky54
+ sslcontext-kickstart-for-netty
+ ${sslcontext.kickstart.version}
+
com.codahale.metrics
metrics-core