This is an automated email from the ASF dual-hosted git repository. bernardobotella pushed a commit to branch trunk in repository https://gitbox.apache.org/repos/asf/cassandra-sidecar.git
The following commit(s) were added to refs/heads/trunk by this push: new 62da993b CASSSIDECAR-228: Enable SSL hot reload certificates (#208) 62da993b is described below commit 62da993b064dd135d2fd719ed6a0154d5571c076 Author: Bernardo Botella <bbote...@users.noreply.github.com> AuthorDate: Fri Mar 21 16:24:13 2025 -0700 CASSSIDECAR-228: Enable SSL hot reload certificates (#208) Patch by Bernardo Botella; reviewed by Francisco Guerrero for CASSSIDECAR-228 --- CHANGES.txt | 1 + .../cassandra/sidecar/client/SidecarClient.java | 4 +- .../DynamicSidecarInstancesProvider.java | 56 ++++++ .../coordination/SidecarHttpHealthProvider.java | 2 +- .../cassandra/sidecar/modules/CdcModule.java | 11 +- .../sidecar/modules/SchedulingModule.java | 20 +- .../modules/multibindings/PeriodicTaskMapKeys.java | 1 + .../sidecar/tasks/KeyStoreCheckPeriodicTask.java | 87 +++++--- .../sidecar/utils/SidecarClientProvider.java | 103 ++++++---- .../org/apache/cassandra/sidecar/TestModule.java | 24 ++- .../apache/cassandra/sidecar/TestSslModule.java | 2 +- .../MutualTLSAuthenticationHandlerTest.java | 2 +- .../sidecar/utils/SidecarClientProviderTest.java | 223 +++++++++++++++++++++ 13 files changed, 454 insertions(+), 82 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index b51f2024..b47c5dc2 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,6 @@ 0.2.0 ----- + * Hot Reload client and server SSL certificates (CASSSIDECAR-228) * Enhance the Cluster Lease Claim task feature (CASSSIDECAR-232) * Capture Metrics for Schema Reporting (CASSSIDECAR-216) * SidecarInstanceCodec is failing to find codec for type (CASSSIDECAR-229) diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarClient.java b/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarClient.java index db1e2c85..71910285 100644 --- a/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarClient.java +++ b/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarClient.java @@ -24,6 +24,7 @@ import java.util.Map; import java.util.Objects; import java.util.UUID; import java.util.concurrent.CompletableFuture; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -124,9 +125,10 @@ public class SidecarClient implements AutoCloseable, SidecarClientBlobRestoreExt /** * Executes the Sidecar health request using the configured selection policy and with no retries * + * @param instance the instance where the request will be executed * @return a completable future of the Sidecar health response */ - public CompletableFuture<HealthResponse> sidecarPeerHealth(SidecarInstance instance) + public CompletableFuture<HealthResponse> sidecarHealth(SidecarInstance instance) { return executor.executeRequestAsync(requestBuilder() .singleInstanceSelectionPolicy(instance) diff --git a/server/src/main/java/org/apache/cassandra/sidecar/coordination/DynamicSidecarInstancesProvider.java b/server/src/main/java/org/apache/cassandra/sidecar/coordination/DynamicSidecarInstancesProvider.java new file mode 100644 index 00000000..7352741d --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/coordination/DynamicSidecarInstancesProvider.java @@ -0,0 +1,56 @@ +/* + * 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.cassandra.sidecar.coordination; + +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.cassandra.sidecar.client.SidecarInstancesProvider; +import org.apache.cassandra.sidecar.cluster.InstancesMetadata; +import org.apache.cassandra.sidecar.common.client.SidecarInstance; +import org.apache.cassandra.sidecar.common.client.SidecarInstanceImpl; +import org.apache.cassandra.sidecar.config.ServiceConfiguration; + +/** + * A {@link SidecarInstancesProvider} implementation that returns Sidecar instances based on the configured + * {@link InstancesMetadata} for the local Sidecar + */ +public class DynamicSidecarInstancesProvider implements SidecarInstancesProvider +{ + private final InstancesMetadata instancesMetadata; + private final ServiceConfiguration serviceConfiguration; + + public DynamicSidecarInstancesProvider(InstancesMetadata instancesMetadata, ServiceConfiguration serviceConfiguration) + { + this.instancesMetadata = instancesMetadata; + this.serviceConfiguration = serviceConfiguration; + } + + /** + * {@inheritDoc} + */ + @Override + public List<SidecarInstance> instances() + { + return instancesMetadata.instances() + .stream() + .map(instanceMetadata -> new SidecarInstanceImpl(instanceMetadata.host(), serviceConfiguration.port())) + .collect(Collectors.toList()); + } +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/coordination/SidecarHttpHealthProvider.java b/server/src/main/java/org/apache/cassandra/sidecar/coordination/SidecarHttpHealthProvider.java index 8cf0cd89..63eef1a3 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/coordination/SidecarHttpHealthProvider.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/coordination/SidecarHttpHealthProvider.java @@ -50,7 +50,7 @@ public class SidecarHttpHealthProvider implements SidecarPeerHealthProvider try { SidecarClient client = clientProvider.get(); - CompletableFuture<HealthResponse> healthRequest = client.sidecarPeerHealth(instance); + CompletableFuture<HealthResponse> healthRequest = client.sidecarHealth(instance); return Future.fromCompletionStage(healthRequest) .map(healthResponse -> healthResponse.isOk() ? Health.UP diff --git a/server/src/main/java/org/apache/cassandra/sidecar/modules/CdcModule.java b/server/src/main/java/org/apache/cassandra/sidecar/modules/CdcModule.java index 450151cb..3b6da2c4 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/modules/CdcModule.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/modules/CdcModule.java @@ -23,10 +23,12 @@ import com.google.inject.Provides; import com.google.inject.Singleton; import com.google.inject.multibindings.ProvidesIntoMap; import org.apache.cassandra.sidecar.cdc.CdcLogCache; +import org.apache.cassandra.sidecar.client.SidecarInstancesProvider; import org.apache.cassandra.sidecar.cluster.InstancesMetadata; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; import org.apache.cassandra.sidecar.config.ServiceConfiguration; import org.apache.cassandra.sidecar.config.SidecarConfiguration; +import org.apache.cassandra.sidecar.coordination.DynamicSidecarInstancesProvider; import org.apache.cassandra.sidecar.coordination.InnerDcTokenAdjacentPeerProvider; import org.apache.cassandra.sidecar.coordination.SidecarHttpHealthProvider; import org.apache.cassandra.sidecar.coordination.SidecarPeerHealthMonitorTask; @@ -67,7 +69,7 @@ public class CdcModule extends AbstractModule { return new ConfigsSchema(serviceConfiguration); } - + @ProvidesIntoMap @KeyClassMapKey(VertxRouteMapKeys.ListCdcSegmentsRouteKey.class) VertxRoute listCdcSegmentsRoute(RouteBuilder.Factory factory, @@ -130,4 +132,11 @@ public class CdcModule extends AbstractModule { return innerDcTokenAdjacentPeerProvider; } + + @Provides + @Singleton + public SidecarInstancesProvider sidecarInstancesProvider(InstancesMetadata instancesMetadata, ServiceConfiguration serviceConfiguration) + { + return new DynamicSidecarInstancesProvider(instancesMetadata, serviceConfiguration); + } } diff --git a/server/src/main/java/org/apache/cassandra/sidecar/modules/SchedulingModule.java b/server/src/main/java/org/apache/cassandra/sidecar/modules/SchedulingModule.java index 6ad8693e..b6d34a67 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/modules/SchedulingModule.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/modules/SchedulingModule.java @@ -18,6 +18,8 @@ package org.apache.cassandra.sidecar.modules; +import java.util.function.Function; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,6 +28,7 @@ import com.google.inject.Provider; import com.google.inject.Provides; import com.google.inject.Singleton; import com.google.inject.multibindings.ProvidesIntoMap; +import io.vertx.core.Future; import io.vertx.core.Vertx; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; import org.apache.cassandra.sidecar.config.SidecarConfiguration; @@ -37,6 +40,7 @@ import org.apache.cassandra.sidecar.server.Server; import org.apache.cassandra.sidecar.tasks.KeyStoreCheckPeriodicTask; import org.apache.cassandra.sidecar.tasks.PeriodicTask; import org.apache.cassandra.sidecar.tasks.PeriodicTaskExecutor; +import org.apache.cassandra.sidecar.utils.SidecarClientProvider; /** * Provides the scheduling capability in Sidecar. Periodic tasks are deployed to {@link PeriodicTaskExecutor} @@ -71,6 +75,20 @@ public class SchedulingModule extends AbstractModule @KeyClassMapKey(PeriodicTaskMapKeys.KeyStoreCheckPeriodicTaskKey.class) PeriodicTask keyStoreCheckPeriodicTask(Vertx vertx, Provider<Server> server, SidecarConfiguration configuration) { - return new KeyStoreCheckPeriodicTask(vertx, server, configuration); + Function<Long, Future<Boolean>> updateServerSSLOptionsFunction = + lastModifiedTime -> server.get().updateSSLOptions(lastModifiedTime).compose(v -> Future.succeededFuture(true)); + + return KeyStoreCheckPeriodicTask.forServer(vertx, configuration, updateServerSSLOptionsFunction); + } + + @ProvidesIntoMap + @KeyClassMapKey(PeriodicTaskMapKeys.ClientKeyStoreCheckPeriodicTaskKey.class) + PeriodicTask clientKeyStoreCheckPeriodicTask(Vertx vertx, + SidecarClientProvider sidecarClientProvider, + SidecarConfiguration configuration) + { + Function<Long, Future<Boolean>> updateClientSSLOptionsFunction = sidecarClientProvider::updateSSLOptions; + + return KeyStoreCheckPeriodicTask.forClient(vertx, configuration, updateClientSSLOptionsFunction); } } diff --git a/server/src/main/java/org/apache/cassandra/sidecar/modules/multibindings/PeriodicTaskMapKeys.java b/server/src/main/java/org/apache/cassandra/sidecar/modules/multibindings/PeriodicTaskMapKeys.java index c6040f59..7099c7dc 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/modules/multibindings/PeriodicTaskMapKeys.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/modules/multibindings/PeriodicTaskMapKeys.java @@ -26,6 +26,7 @@ public interface PeriodicTaskMapKeys interface ClusterLeaseClaimTaskKey extends ClassKey {} interface HealthCheckPeriodicTaskKey extends ClassKey {} interface KeyStoreCheckPeriodicTaskKey extends ClassKey {} + interface ClientKeyStoreCheckPeriodicTaskKey extends ClassKey {} interface RestoreJobDiscovererKey extends ClassKey {} interface RestoreProcessorKey extends ClassKey {} interface RingTopologyRefresherKey extends ClassKey {} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/tasks/KeyStoreCheckPeriodicTask.java b/server/src/main/java/org/apache/cassandra/sidecar/tasks/KeyStoreCheckPeriodicTask.java index e079359b..b7fe64ad 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/tasks/KeyStoreCheckPeriodicTask.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/tasks/KeyStoreCheckPeriodicTask.java @@ -18,16 +18,17 @@ package org.apache.cassandra.sidecar.tasks; +import java.util.function.Function; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.inject.Provider; +import io.vertx.core.Future; import io.vertx.core.Promise; import io.vertx.core.Vertx; import org.apache.cassandra.sidecar.common.server.utils.DurationSpec; import org.apache.cassandra.sidecar.config.SidecarConfiguration; import org.apache.cassandra.sidecar.config.SslConfiguration; -import org.apache.cassandra.sidecar.server.Server; import org.apache.cassandra.sidecar.utils.EventBusUtils; import static org.apache.cassandra.sidecar.server.SidecarServerEvents.ON_SERVER_START; @@ -41,25 +42,56 @@ public class KeyStoreCheckPeriodicTask implements PeriodicTask private static final Logger LOGGER = LoggerFactory.getLogger(KeyStoreCheckPeriodicTask.class); private final Vertx vertx; - private final Provider<Server> server; - private final SslConfiguration configuration; + private final SslConfiguration sslConfiguration; + private final Function<Long, Future<Boolean>> updateSSLOptionsFunction; private long lastModifiedTime = 0; // records the last modified timestamp + private final String taskName; - public KeyStoreCheckPeriodicTask(Vertx vertx, Provider<Server> server, SidecarConfiguration configuration) + protected KeyStoreCheckPeriodicTask(Vertx vertx, + SslConfiguration sslConfiguration, + Function<Long, Future<Boolean>> updateSSLOptionsFunction, + String taskName) { this.vertx = vertx; - this.server = server; - this.configuration = configuration.sslConfiguration(); + this.sslConfiguration = sslConfiguration; + this.updateSSLOptionsFunction = updateSSLOptionsFunction; + this.taskName = taskName; + } + + public static KeyStoreCheckPeriodicTask forServer(Vertx vertx, + SidecarConfiguration configuration, + Function<Long, Future<Boolean>> updateSSLOptionsFunction) + { + return new KeyStoreCheckPeriodicTask(vertx, + configuration.sslConfiguration(), + updateSSLOptionsFunction, + "ServerKeyStoreCheckPeriodicTask"); + } + + public static KeyStoreCheckPeriodicTask forClient(Vertx vertx, + SidecarConfiguration configuration, + Function<Long, Future<Boolean>> updateSSLOptionsFunction) + { + return new KeyStoreCheckPeriodicTask(vertx, + configuration.sidecarClientConfiguration().sslConfiguration(), + updateSSLOptionsFunction, + "ClientKeyStoreCheckPeriodicTask"); + } + + @Override + public String name() + { + return taskName; } @Override public void deploy(Vertx vertx, PeriodicTaskExecutor executor) { - if (configuration != null - && configuration.enabled() - && configuration.keystore() != null - && configuration.keystore().isConfigured() - && configuration.keystore().reloadStore()) + if (sslConfiguration != null + && sslConfiguration.enabled() + && sslConfiguration.keystore() != null + && sslConfiguration.keystore().isConfigured() + && sslConfiguration.keystore().reloadStore()) { maybeRecordLastModifiedTime(); EventBusUtils.onceLocalConsumer(vertx.eventBus(), ON_SERVER_START.address(), message -> executor.schedule(this)); @@ -82,14 +114,14 @@ public class KeyStoreCheckPeriodicTask implements PeriodicTask @Override public DurationSpec delay() { - return configuration.keystore().checkInterval(); + return sslConfiguration.keystore().checkInterval(); } @Override public void execute(Promise<Void> promise) { LOGGER.info("Running periodic key store checker"); - String keyStorePath = configuration.keystore().path(); + String keyStorePath = sslConfiguration.keystore().path(); vertx.fileSystem().props(keyStorePath) .onSuccess(props -> { long previousLastModifiedTime = lastModifiedTime; @@ -99,17 +131,16 @@ public class KeyStoreCheckPeriodicTask implements PeriodicTask "lastModifiedTime={}", keyStorePath, previousLastModifiedTime, props.lastModifiedTime()); - server.get() - .updateSSLOptions(props.lastModifiedTime()) - .onSuccess(v -> { - lastModifiedTime = props.lastModifiedTime(); - LOGGER.info("Completed reloading certificates from path={}", keyStorePath); - promise.complete(); // propagate successful completion - }) - .onFailure(cause -> { - LOGGER.error("Failed to reload certificate from path={}", keyStorePath, cause); - promise.fail(cause); - }); + updateSSLOptionsFunction.apply(props.lastModifiedTime()) + .onSuccess(v -> { + lastModifiedTime = props.lastModifiedTime(); + LOGGER.info("Completed reloading certificates from path={}", keyStorePath); + promise.complete(); // propagate successful completion + }) + .onFailure(cause -> { + LOGGER.error("Failed to reload certificate from path={}", keyStorePath, cause); + promise.fail(cause); + }); } else { @@ -128,7 +159,7 @@ public class KeyStoreCheckPeriodicTask implements PeriodicTask { return; } - String keyStorePath = configuration.keystore().path(); + String keyStorePath = sslConfiguration.keystore().path(); vertx.fileSystem().props(keyStorePath) .onSuccess(props -> lastModifiedTime = props.lastModifiedTime()) .onFailure(err -> { @@ -145,7 +176,7 @@ public class KeyStoreCheckPeriodicTask implements PeriodicTask */ private boolean shouldSkip() { - return !configuration.isKeystoreConfigured() - || !configuration.keystore().reloadStore(); + return !sslConfiguration.isKeystoreConfigured() + || !sslConfiguration.keystore().reloadStore(); } } diff --git a/server/src/main/java/org/apache/cassandra/sidecar/utils/SidecarClientProvider.java b/server/src/main/java/org/apache/cassandra/sidecar/utils/SidecarClientProvider.java index a5d3ea68..6d06a866 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/utils/SidecarClientProvider.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/utils/SidecarClientProvider.java @@ -18,7 +18,7 @@ package org.apache.cassandra.sidecar.utils; -import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.concurrent.atomic.AtomicBoolean; import org.slf4j.Logger; @@ -27,10 +27,10 @@ import org.slf4j.LoggerFactory; import com.google.inject.Inject; import com.google.inject.Provider; import com.google.inject.Singleton; +import io.vertx.core.Future; import io.vertx.core.Vertx; -import io.vertx.core.http.HttpClient; -import io.vertx.core.net.JksOptions; import io.vertx.core.net.OpenSSLEngineOptions; +import io.vertx.core.net.SSLOptions; import io.vertx.ext.web.client.WebClient; import io.vertx.ext.web.client.WebClientOptions; import org.apache.cassandra.sidecar.client.HttpClientConfig; @@ -38,16 +38,16 @@ import org.apache.cassandra.sidecar.client.SidecarClient; import org.apache.cassandra.sidecar.client.SidecarClientConfig; import org.apache.cassandra.sidecar.client.SidecarClientConfigImpl; import org.apache.cassandra.sidecar.client.SidecarClientVertxRequestExecutor; -import org.apache.cassandra.sidecar.client.SimpleSidecarInstancesProvider; +import org.apache.cassandra.sidecar.client.SidecarInstancesProvider; import org.apache.cassandra.sidecar.client.VertxHttpClient; +import org.apache.cassandra.sidecar.client.VertxRequestExecutor; import org.apache.cassandra.sidecar.client.retry.ExponentialBackoffRetryPolicy; import org.apache.cassandra.sidecar.client.retry.RetryPolicy; -import org.apache.cassandra.sidecar.common.client.SidecarInstance; -import org.apache.cassandra.sidecar.common.client.SidecarInstanceImpl; import org.apache.cassandra.sidecar.common.server.utils.SidecarVersionProvider; import org.apache.cassandra.sidecar.common.server.utils.ThrowableUtils; import org.apache.cassandra.sidecar.config.SidecarClientConfiguration; import org.apache.cassandra.sidecar.config.SidecarConfiguration; +import org.apache.cassandra.sidecar.config.SslConfiguration; /** * Provider class for retrieving the singleton {@link SidecarClient} instance @@ -57,21 +57,28 @@ public class SidecarClientProvider implements Provider<SidecarClient> { private static final Logger LOGGER = LoggerFactory.getLogger(SidecarClientProvider.class); private final Vertx vertx; - private final SidecarClientConfiguration clientConfig; + private final SidecarInstancesProvider sidecarInstancesProvider; private final SidecarVersionProvider sidecarVersionProvider; private final SidecarClient client; + private final WebClient webClient; private final AtomicBoolean isClosing = new AtomicBoolean(false); + private final WebClientOptions webClientOptions; + private final SidecarClientConfiguration sidecarClientConfiguration; @Inject public SidecarClientProvider(Vertx vertx, SidecarConfiguration sidecarConfiguration, + SidecarInstancesProvider sidecarInstancesProvider, SidecarVersionProvider sidecarVersionProvider) { this.vertx = vertx; - this.clientConfig = sidecarConfiguration.sidecarClientConfiguration(); + this.sidecarInstancesProvider = sidecarInstancesProvider; this.sidecarVersionProvider = sidecarVersionProvider; - this.client = initializeSidecarClient(); + this.sidecarClientConfiguration = sidecarConfiguration.sidecarClientConfiguration(); + this.webClientOptions = webClientOptions(sidecarClientConfiguration); + this.webClient = WebClient.create(vertx, webClientOptions); + this.client = initializeSidecarClient(sidecarClientConfiguration); } @Override @@ -80,6 +87,19 @@ public class SidecarClientProvider implements Provider<SidecarClient> return client; } + /** + * Updates the SSL Options for the client + * + * @param lastModifiedTime the time of last modification for the file + * @return a future with the result of the update + */ + public Future<Boolean> updateSSLOptions(long lastModifiedTime) + { + SSLOptions sslOptions = webClientOptions.getSslOptions(); + configureSSLOptions(sslOptions, sidecarClientConfiguration.sslConfiguration(), lastModifiedTime); + return webClient.updateSSLOptions(sslOptions); + } + public void close() { if (isClosing.compareAndSet(false, true)) @@ -89,14 +109,10 @@ public class SidecarClientProvider implements Provider<SidecarClient> } } - private SidecarClient initializeSidecarClient() + private SidecarClient initializeSidecarClient(SidecarClientConfiguration clientConfig) { - WebClientOptions webClientOptions = webClientOptions(); - HttpClient httpClient = vertx.createHttpClient(webClientOptions); - WebClient webClient = WebClient.wrap(httpClient, webClientOptions); - HttpClientConfig httpClientConfig = new HttpClientConfig.Builder<>() - .ssl(webClientOptions().isSsl()) + .ssl(webClientOptions.isSsl()) .timeoutMillis(clientConfig.requestTimeout().toMillis()) .idleTimeoutMillis(clientConfig.requestIdleTimeout().toIntMillis()) .userAgent("cassandra-sidecar/" + sidecarVersionProvider.sidecarVersion()) @@ -105,26 +121,21 @@ public class SidecarClientProvider implements Provider<SidecarClient> VertxHttpClient vertxHttpClient = new VertxHttpClient(vertx, webClient, httpClientConfig); RetryPolicy defaultRetryPolicy = new ExponentialBackoffRetryPolicy(clientConfig.maxRetries(), clientConfig.retryDelay().toMillis(), - clientConfig.retryDelay().toMillis()); - SidecarClientVertxRequestExecutor requestExecutor = new SidecarClientVertxRequestExecutor(vertxHttpClient); - SidecarInstance instance = new SidecarInstanceImpl(webClientOptions.getDefaultHost(), webClientOptions.getDefaultPort()); - ArrayList<SidecarInstance> instances = new ArrayList<>(); - instances.add(instance); - SimpleSidecarInstancesProvider instancesProvider = new SimpleSidecarInstancesProvider(instances); + clientConfig.maxRetryDelay().toMillis()); + VertxRequestExecutor requestExecutor = new SidecarClientVertxRequestExecutor(vertxHttpClient); SidecarClientConfig config = SidecarClientConfigImpl.builder() .retryDelayMillis(clientConfig.retryDelay().toMillis()) .maxRetryDelayMillis(clientConfig.maxRetryDelay().toMillis()) .maxRetries(clientConfig.maxRetries()) .build(); - - return new SidecarClient(instancesProvider, + return new SidecarClient(sidecarInstancesProvider, requestExecutor, config, defaultRetryPolicy); } - private WebClientOptions webClientOptions() + static WebClientOptions webClientOptions(SidecarClientConfiguration clientConfig) { WebClientOptions options = new WebClientOptions(); options.getPoolOptions() @@ -133,31 +144,49 @@ public class SidecarClientProvider implements Provider<SidecarClient> .setHttp1MaxSize(clientConfig.connectionPoolMaxSize()) .setMaxWaitQueueSize(clientConfig.connectionPoolMaxWaitQueueSize()); - boolean useSsl = clientConfig.sslConfiguration() != null && clientConfig.sslConfiguration().enabled(); - if (clientConfig.sslConfiguration() != null && clientConfig.sslConfiguration().isKeystoreConfigured()) + SslConfiguration ssl = clientConfig.sslConfiguration(); + if (ssl != null && ssl.enabled()) { - options.setKeyStoreOptions(new JksOptions().setPath(clientConfig.sslConfiguration().keystore().path()) - .setPassword(clientConfig.sslConfiguration().keystore().password())); - if (clientConfig.sslConfiguration().preferOpenSSL() && OpenSSLEngineOptions.isAvailable()) + options.setSsl(true); + + if (!ssl.secureTransportProtocols().isEmpty()) + { + // Use LinkedHashSet to preserve input order + options.setEnabledSecureTransportProtocols(new LinkedHashSet<>(ssl.secureTransportProtocols())); + } + + for (String cipherSuite : ssl.cipherSuites()) + { + options.addEnabledCipherSuite(cipherSuite); + } + + if (ssl.preferOpenSSL() && OpenSSLEngineOptions.isAvailable()) { LOGGER.info("Using OpenSSL for encryption in Webclient Options"); - useSsl = true; options.setSslEngineOptions(new OpenSSLEngineOptions().setSessionCacheEnabled(true)); } else { LOGGER.warn("OpenSSL not enabled, using JDK for TLS in Webclient Options"); } + + configureSSLOptions(options.getSslOptions(), ssl, 0); } + return options; + } + + static void configureSSLOptions(SSLOptions options, SslConfiguration ssl, long timestamp) + { + options.setSslHandshakeTimeout(ssl.handshakeTimeout().quantity()) + .setSslHandshakeTimeoutUnit(ssl.handshakeTimeout().unit()); - if (clientConfig.sslConfiguration() != null && clientConfig.sslConfiguration().truststore() != null - && clientConfig.sslConfiguration().truststore().isConfigured()) + if (ssl.isKeystoreConfigured()) { - options.setTrustStoreOptions(new JksOptions().setPath(clientConfig.sslConfiguration().truststore().path()) - .setPassword(clientConfig.sslConfiguration().truststore().password())); + SslUtils.setKeyStoreConfiguration(options, ssl.keystore(), timestamp); + } + if (ssl.isTrustStoreConfigured()) + { + SslUtils.setTrustStoreConfiguration(options, ssl.truststore()); } - - options.setSsl(useSsl); - return options; } } diff --git a/server/src/test/java/org/apache/cassandra/sidecar/TestModule.java b/server/src/test/java/org/apache/cassandra/sidecar/TestModule.java index 7649b11b..5644f67f 100644 --- a/server/src/test/java/org/apache/cassandra/sidecar/TestModule.java +++ b/server/src/test/java/org/apache/cassandra/sidecar/TestModule.java @@ -23,6 +23,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.TimeUnit; +import java.util.function.Function; import com.google.common.util.concurrent.SidecarRateLimiter; @@ -45,7 +46,6 @@ import org.apache.cassandra.sidecar.common.server.StorageOperations; import org.apache.cassandra.sidecar.common.server.dns.DnsResolver; import org.apache.cassandra.sidecar.common.server.utils.MillisecondBoundConfiguration; import org.apache.cassandra.sidecar.common.server.utils.SecondBoundConfiguration; -import org.apache.cassandra.sidecar.config.AccessControlConfiguration; import org.apache.cassandra.sidecar.config.CdcConfiguration; import org.apache.cassandra.sidecar.config.PeriodicTaskConfiguration; import org.apache.cassandra.sidecar.config.RestoreJobConfiguration; @@ -55,7 +55,6 @@ import org.apache.cassandra.sidecar.config.ServiceConfiguration; import org.apache.cassandra.sidecar.config.SidecarConfiguration; import org.apache.cassandra.sidecar.config.SslConfiguration; import org.apache.cassandra.sidecar.config.ThrottleConfiguration; -import org.apache.cassandra.sidecar.config.yaml.AccessControlConfigurationImpl; import org.apache.cassandra.sidecar.config.yaml.CdcConfigurationImpl; import org.apache.cassandra.sidecar.config.yaml.PeriodicTaskConfigurationImpl; import org.apache.cassandra.sidecar.config.yaml.RestoreJobConfigurationImpl; @@ -102,11 +101,11 @@ public class TestModule extends AbstractModule protected SidecarConfigurationImpl abstractConfig(SslConfiguration sslConfiguration) { - return abstractConfig(sslConfiguration, new AccessControlConfigurationImpl()); + return abstractConfig(sslConfiguration, null); } protected SidecarConfigurationImpl abstractConfig(SslConfiguration sslConfiguration, - AccessControlConfiguration accessControlConfiguration) + Function<SidecarConfigurationImpl.Builder, SidecarConfigurationImpl.Builder> configurationOverrides) { ThrottleConfiguration throttleConfiguration = new ThrottleConfigurationImpl(5, SecondBoundConfiguration.parse("5s")); SSTableUploadConfiguration uploadConfiguration = new SSTableUploadConfigurationImpl(0F); @@ -136,13 +135,16 @@ public class TestModule extends AbstractModule = new PeriodicTaskConfigurationImpl(true, MillisecondBoundConfiguration.parse("200ms"), MillisecondBoundConfiguration.parse("1s")); - return SidecarConfigurationImpl.builder() - .serviceConfiguration(serviceConfiguration) - .sslConfiguration(sslConfiguration) - .accessControlConfiguration(accessControlConfiguration) - .restoreJobConfiguration(restoreJobConfiguration) - .healthCheckConfiguration(healthCheckConfiguration) - .build(); + SidecarConfigurationImpl.Builder builder = SidecarConfigurationImpl.builder() + .serviceConfiguration(serviceConfiguration) + .sslConfiguration(sslConfiguration) + .restoreJobConfiguration(restoreJobConfiguration) + .healthCheckConfiguration(healthCheckConfiguration); + if (configurationOverrides != null) + { + builder = configurationOverrides.apply(builder); + } + return builder.build(); } @Provides diff --git a/server/src/test/java/org/apache/cassandra/sidecar/TestSslModule.java b/server/src/test/java/org/apache/cassandra/sidecar/TestSslModule.java index 43b2603d..1f074ff6 100644 --- a/server/src/test/java/org/apache/cassandra/sidecar/TestSslModule.java +++ b/server/src/test/java/org/apache/cassandra/sidecar/TestSslModule.java @@ -58,7 +58,7 @@ public class TestSslModule extends TestModule if (!Files.exists(keyStorePath)) { - logger.error("JMX password file not found in path={}", keyStorePath); + logger.error("Keystore file not found in path={}", keyStorePath); } if (!Files.exists(trustStorePath)) { diff --git a/server/src/test/java/org/apache/cassandra/sidecar/acl/authentication/MutualTLSAuthenticationHandlerTest.java b/server/src/test/java/org/apache/cassandra/sidecar/acl/authentication/MutualTLSAuthenticationHandlerTest.java index b6923406..0b44cb6c 100644 --- a/server/src/test/java/org/apache/cassandra/sidecar/acl/authentication/MutualTLSAuthenticationHandlerTest.java +++ b/server/src/test/java/org/apache/cassandra/sidecar/acl/authentication/MutualTLSAuthenticationHandlerTest.java @@ -333,7 +333,7 @@ class MutualTLSAuthenticationHandlerTest Collections.singleton(ADMIN_IDENTITY), new CacheConfigurationImpl()); - return super.abstractConfig(sslConfiguration, accessControlConfiguration); + return super.abstractConfig(sslConfiguration, builder -> builder.accessControlConfiguration(accessControlConfiguration)); } @Provides diff --git a/server/src/test/java/org/apache/cassandra/sidecar/utils/SidecarClientProviderTest.java b/server/src/test/java/org/apache/cassandra/sidecar/utils/SidecarClientProviderTest.java new file mode 100644 index 00000000..1f3d7187 --- /dev/null +++ b/server/src/test/java/org/apache/cassandra/sidecar/utils/SidecarClientProviderTest.java @@ -0,0 +1,223 @@ +/* + * 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.cassandra.sidecar.utils; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.google.inject.util.Modules; +import io.vertx.core.Vertx; +import org.apache.cassandra.sidecar.TestModule; +import org.apache.cassandra.sidecar.client.SidecarClient; +import org.apache.cassandra.sidecar.common.client.SidecarInstanceImpl; +import org.apache.cassandra.sidecar.common.response.HealthResponse; +import org.apache.cassandra.sidecar.common.server.utils.SecondBoundConfiguration; +import org.apache.cassandra.sidecar.config.SidecarClientConfiguration; +import org.apache.cassandra.sidecar.config.SslConfiguration; +import org.apache.cassandra.sidecar.config.yaml.KeyStoreConfigurationImpl; +import org.apache.cassandra.sidecar.config.yaml.SidecarClientConfigurationImpl; +import org.apache.cassandra.sidecar.config.yaml.SidecarConfigurationImpl; +import org.apache.cassandra.sidecar.config.yaml.SslConfigurationImpl; +import org.apache.cassandra.sidecar.modules.SidecarModules; +import org.apache.cassandra.sidecar.server.Server; +import org.apache.cassandra.testing.utils.tls.CertificateBuilder; +import org.apache.cassandra.testing.utils.tls.CertificateBundle; + +import static org.apache.cassandra.testing.utils.AssertionUtils.getBlocking; +import static org.apache.cassandra.testing.utils.AssertionUtils.loopAssert; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.fail; + +/** + * Unit test for the {@link SidecarClientProvider} class + */ +class SidecarClientProviderTest +{ + public static final char[] EMPTY_PASSWORD = new char[0]; + + @TempDir + static Path secretsPath; + static Path truststorePath; + static Path serverKeyStorePath; + static Path validClientCertPath; + static Path clientCertPath; + + Injector injector; + private Vertx vertx; + private Server server; + + SidecarClient client; + TestModule testModule; + + private SidecarClientProvider provider; + + @BeforeAll + static void configureCertificates() throws Exception + { + CertificateBundle certificateAuthority = new CertificateBuilder().subject("CN=Apache Cassandra Root CA, OU=Certification Authority, O=Unknown, C=Unknown") + .alias("fakerootca") + .isCertificateAuthority(true) + .buildSelfSigned(); + truststorePath = certificateAuthority.toTempKeyStorePath(secretsPath, EMPTY_PASSWORD, EMPTY_PASSWORD); + + CertificateBuilder serverKeyStoreBuilder = + new CertificateBuilder().subject("CN=Apache Cassandra, OU=mtls_test, O=Unknown, L=Unknown, ST=Unknown, C=Unknown") + .addSanDnsName("localhost"); + CertificateBundle serverKeyStore = serverKeyStoreBuilder.buildIssuedBy(certificateAuthority); + serverKeyStorePath = serverKeyStore.toTempKeyStorePath(secretsPath, EMPTY_PASSWORD, EMPTY_PASSWORD); + + CertificateBundle expiredClientKeyStore = new CertificateBuilder().subject("CN=Apache Cassandra, OU=mtls_test, O=Unknown, L=Unknown, ST=Unknown, C=Unknown") + .addSanDnsName("localhost") + .notBefore(Instant.now().minus(7, ChronoUnit.DAYS)) + .notAfter(Instant.now().minus(1, ChronoUnit.DAYS)) + .buildIssuedBy(certificateAuthority); + // Assign the expired client cert to the cert path + clientCertPath = expiredClientKeyStore.toTempKeyStorePath(secretsPath, EMPTY_PASSWORD, EMPTY_PASSWORD); + + CertificateBundle validClientKeyStore = new CertificateBuilder().subject("CN=Apache Cassandra, OU=mtls_test, O=Unknown, L=Unknown, ST=Unknown, C=Unknown") + .addSanDnsName("localhost") + .buildIssuedBy(certificateAuthority); + validClientCertPath = validClientKeyStore.toTempKeyStorePath(secretsPath, EMPTY_PASSWORD, EMPTY_PASSWORD); + } + + @BeforeEach + void setup() + { + testModule = new SidecarClientProviderModule(); + + injector = Guice.createInjector(Modules.override(SidecarModules.all()).with(testModule)); + vertx = injector.getInstance(Vertx.class); + server = getMTLSServerAndStart(); + provider = injector.getInstance(SidecarClientProvider.class); + client = provider.get(); + } + + @AfterEach + void cleanup() + { + if (server != null) + { + getBlocking(server.close(), 10, TimeUnit.SECONDS, "Close server"); + } + getBlocking(vertx.close(), 10, TimeUnit.SECONDS, "Close vertx"); + } + + @Test + void testSidecarClientIsSingleton() + { + SidecarClient client1 = provider.get(); + SidecarClient client2 = provider.get(); + + assertThat(client1).isSameAs(client2); + } + + @Test + void testHotReloadOfClientCerts() throws Exception + { + // the certificate should be expired at the beginning of the test + unsuccessfulClientRequest(client); + + // Replace the expired certificated with a good certificate we can use + Files.copy(validClientCertPath, clientCertPath, StandardCopyOption.REPLACE_EXISTING); + + // Wait until the client reloads the certificate + loopAssert(10, () -> successfulClientRequest(client)); + + // Execute requests with the client. We should see successful requests go through now + successfulClientRequest(client); + } + + private void unsuccessfulClientRequest(SidecarClient client) + { + assertThatThrownBy(() -> client.sidecarHealth(new SidecarInstanceImpl("localhost", server.actualPort())).get(30, TimeUnit.SECONDS)) + .describedAs("Unsuccessful client requests are expected to fail") + .isNotNull(); + } + + private void successfulClientRequest(SidecarClient client) + { + HealthResponse healthResponse = null; + try + { + healthResponse = client.sidecarHealth(new SidecarInstanceImpl("localhost", server.actualPort())).get(30, TimeUnit.SECONDS); + } + catch (Exception exception) + { + fail("Client request was expected to succeed", exception); + } + assertThat(healthResponse).isNotNull(); + assertThat(healthResponse.isOk()).isTrue(); + } + + Server getMTLSServerAndStart() + { + // Start server and wait for it to be running + Server server = injector.getInstance(Server.class); + getBlocking(server.start(), 30, TimeUnit.SECONDS, "Server start"); + return server; + } + + static class SidecarClientProviderModule extends TestModule + { + @Override + public SidecarConfigurationImpl abstractConfig() + { + SslConfiguration serverSslConfiguration = + SslConfigurationImpl.builder() + .enabled(true) + .useOpenSsl(true) + .handshakeTimeout(SecondBoundConfiguration.parse("10s")) + .clientAuth("REQUIRED") + .keystore(new KeyStoreConfigurationImpl(serverKeyStorePath.toAbsolutePath().toString(), "")) + .truststore(new KeyStoreConfigurationImpl(truststorePath.toAbsolutePath().toString(), "")) + .build(); + + Function<SidecarConfigurationImpl.Builder, SidecarConfigurationImpl.Builder> configOverrides = + builder -> { + String type = "PKCS12"; + SecondBoundConfiguration checkInterval = SecondBoundConfiguration.ONE; + + SslConfiguration clientSslConfiguration = + SslConfigurationImpl.builder() + .enabled(true) + .useOpenSsl(true) + .keystore(new KeyStoreConfigurationImpl(clientCertPath.toAbsolutePath().toString(), "", type, checkInterval)) + .truststore(new KeyStoreConfigurationImpl(truststorePath.toAbsolutePath().toString(), "", type, checkInterval)) + .build(); + SidecarClientConfiguration sidecarClientConfiguration = new SidecarClientConfigurationImpl(clientSslConfiguration); + return builder.sidecarClientConfiguration(sidecarClientConfiguration); + }; + return super.abstractConfig(serverSslConfiguration, configOverrides); + } + } +} --------------------------------------------------------------------- To unsubscribe, e-mail: commits-unsubscr...@cassandra.apache.org For additional commands, e-mail: commits-h...@cassandra.apache.org