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>

Reply via email to