This is an automated email from the ASF dual-hosted git repository.
colegreer pushed a commit to branch 3.7-dev
in repository https://gitbox.apache.org/repos/asf/tinkerpop.git
The following commit(s) were added to refs/heads/3.7-dev by this push:
new 8f25b748cf [TINKERPOP-3146] Support hot reloading of SSL certificates
(#3078)
8f25b748cf is described below
commit 8f25b748cfab0ee046ec8e14f59a63e34eaa76ab
Author: Clement de Groc <[email protected]>
AuthorDate: Sat May 3 07:05:59 2025 +0200
[TINKERPOP-3146] Support hot reloading of SSL certificates (#3078)
Adds a file watcher that monitors certificate files for changes every
minute. When changes are detected, the certificates are reloaded.
Integrates the (Apache License) Hakky54/sslcontext-kickstart library (as
suggested by the TinkerPop community) to enable hot-reloading of SSL
certificates.
---
CHANGELOG.asciidoc | 1 +
gremlin-server/pom.xml | 10 ++
.../gremlin/server/AbstractChannelizer.java | 95 +++++++++-------
.../apache/tinkerpop/gremlin/server/Settings.java | 6 +
.../util/SSLStoreFilesModificationWatcher.java | 124 +++++++++++++++++++++
.../util/SSLStoreFilesModificationWatcherTest.java | 65 +++++++++++
pom.xml | 6 +
7 files changed, 264 insertions(+), 43 deletions(-)
diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc
index 8e0d126c10..97f29200ff 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 80fc2cb60e..97ed90a1d7 100644
--- a/gremlin-server/pom.xml
+++ b/gremlin-server/pom.xml
@@ -59,6 +59,16 @@ limitations under the License.
<artifactId>logback-classic</artifactId>
<optional>true</optional>
</dependency>
+ <dependency>
+ <groupId>io.github.hakky54</groupId>
+ <artifactId>sslcontext-kickstart-for-netty</artifactId>
+ <exclusions>
+ <exclusion>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-api</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
<!-- METRICS -->
<dependency>
<groupId>com.codahale.metrics</groupId>
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 c133f68652..9af52699dc 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 @@ package org.apache.tinkerpop.gremlin.server;
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.javatuples.Pair;
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.Map;
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 abstract class AbstractChannelizer extends
ChannelInitializer<SocketChann
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 @@ public abstract class AbstractChannelizer extends
ChannelInitializer<SocketChann
}
}
- 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 7a21afcf0d..8968fa2daa 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 class Settings {
*/
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 0000000000..fc80e151a9
--- /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 0000000000..46fe502d4d
--- /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 02f4e96a79..50aad5a6e8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -178,6 +178,7 @@ limitations under the License.
<slf4j.version>1.7.25</slf4j.version>
<snakeyaml.version>2.0</snakeyaml.version>
<spark.version>3.3.2</spark.version>
+ <sslcontext.kickstart.version>9.1.0</sslcontext.kickstart.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
@@ -781,6 +782,11 @@ limitations under the License.
<artifactId>commons-lang3</artifactId>
<version>${commons.lang3.version}</version>
</dependency>
+ <dependency>
+ <groupId>io.github.hakky54</groupId>
+ <artifactId>sslcontext-kickstart-for-netty</artifactId>
+ <version>${sslcontext.kickstart.version}</version>
+ </dependency>
<dependency>
<groupId>com.codahale.metrics</groupId>
<artifactId>metrics-core</artifactId>