Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Watch the keystore and truststore directories for changes #1466

Merged
merged 4 commits into from
Feb 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import javax.ws.rs.DefaultValue;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.annotations.VisibleForTesting;
import io.dropwizard.Configuration;
import io.dropwizard.client.HttpClientConfiguration;
import org.apache.cassandra.repair.RepairParallelism;
Expand Down Expand Up @@ -735,6 +736,21 @@ public String getTruststore() {
return truststore;
}

@VisibleForTesting
public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}

@VisibleForTesting
public void setKeystore(String keystore) {
this.keystore = keystore;
}

@VisibleForTesting
public void setTruststore(String truststore) {
this.truststore = truststore;
}

public int getMgmtApiMetricsPort() {
return mgmtApiMetricsPort == null ? DEFAULT_MGMT_API_METRICS_PORT : mgmtApiMetricsPort;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,15 @@
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.UnknownHostException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.nio.file.StandardWatchEventKinds;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
Expand All @@ -45,6 +50,8 @@
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
Expand Down Expand Up @@ -88,6 +95,14 @@ public HttpManagementConnectionFactory(AppContext context, ScheduledExecutorServ
this.config = context.config;
registerConnectionsGauge();
this.jobStatusPollerExecutor = jobStatusPollerExecutor;
if (context.config.getHttpManagement().getKeystore() != null && !context.config.getHttpManagement().getKeystore()
.isEmpty()) {
try {
createSslWatcher();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

@Override
Expand Down Expand Up @@ -152,7 +167,6 @@ private HttpCassandraManagementProxy connectImpl(Node node)

LOG.trace("Wanting to create new connection to " + node.getHostname());
return HTTP_CONNECTIONS.computeIfAbsent(node.getHostname(), new Function<String, HttpCassandraManagementProxy>() {
@Nullable
@Override
public HttpCassandraManagementProxy apply(@Nullable String hostName) {
ReaperApplicationConfiguration.HttpManagement httpConfig = config.getHttpManagement();
Expand Down Expand Up @@ -237,7 +251,70 @@ private TrustManager[] getTrustManagers() throws ReaperException {
} catch (IOException | NoSuchAlgorithmException | KeyStoreException | CertificateException e) {
throw new ReaperException(e);
}
}

@VisibleForTesting
void createSslWatcher() throws IOException {
WatchService watchService = FileSystems.getDefault().newWatchService();
Path trustStorePath = Paths.get(config.getHttpManagement().getTruststore());
Path keyStorePath = Paths.get(config.getHttpManagement().getKeystore());
Path keystoreParent = trustStorePath.getParent();
Path trustStoreParent = keyStorePath.getParent();

keystoreParent.register(
watchService,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_DELETE,
StandardWatchEventKinds.ENTRY_MODIFY);

if (!keystoreParent.equals(trustStoreParent)) {
trustStoreParent.register(
watchService,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_DELETE,
StandardWatchEventKinds.ENTRY_MODIFY);
}

ExecutorService executorService = Executors.newSingleThreadExecutor();
executorService.execute(
() -> {
while (true) {
try {
clearHttpConnections();
WatchKey key = watchService.take();
List<WatchEvent<?>> events = key.pollEvents();
boolean reloadNeeded = false;
for (WatchEvent<?> event : events) {
WatchEvent.Kind<?> kind = event.kind();

WatchEvent<java.nio.file.Path> ev = (WatchEvent<Path>) event;
Path eventFilename = ev.context();

if (keystoreParent.resolve(eventFilename).equals(keyStorePath)
|| trustStoreParent.resolve(eventFilename).equals(trustStorePath)) {
// Something in the TLS has been modified.. recreate HTTP connections
reloadNeeded = true;
}
}
if (!key.reset()) {
// The watched directories have disappeared..
break;
}
if (reloadNeeded) {
LOG.info("Detected change in the SSL/TLS certificates, reloading.");
clearHttpConnections();
}
} catch (InterruptedException e) {
LOG.error("Filesystem watcher received InterruptedException", e);
}
}
});
}

@VisibleForTesting
void clearHttpConnections() {
// Clearing this causes the connectImpl() to recreate new SSLContext
HTTP_CONNECTIONS.clear();
}

private Response getPid(Node node) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

package io.cassandrareaper.management.http;

import io.cassandrareaper.AppContext;
import io.cassandrareaper.ReaperApplicationConfiguration;
import io.cassandrareaper.ReaperException;
import io.cassandrareaper.core.GenericMetric;
Expand All @@ -29,6 +30,9 @@

import java.math.BigInteger;
import java.net.InetSocketAddress;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
Expand Down Expand Up @@ -74,6 +78,7 @@
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
Expand Down Expand Up @@ -798,4 +803,36 @@ public void testIsCoordinatorNode_NoTokens() {
endpointState.put("OTHER", "value");
assertTrue(HttpCassandraManagementProxy.isCoordinatorNode(endpointState));
}

@Test
public void testSSLHotReload() throws Exception {
AppContext context = mock(AppContext.class);
ReaperApplicationConfiguration config = new ReaperApplicationConfiguration();
context.config = config;

Path tempDirectory = Files.createTempDirectory("reload-test");
Path ks = Paths.get("/home/runner/work/cassandra-reaper/cassandra-reaper/.github/files/keystore.jks");
Path ts = Paths.get("/home/runner/work/cassandra-reaper/cassandra-reaper/.github/files/truststore.jks");

Path tsCopy =
Files.copy(ts, tempDirectory.resolve(ts.getFileName()));
Path ksCopy =
Files.copy(ks, tempDirectory.resolve(ks.getFileName()));

config.getHttpManagement().setEnabled(true);
config.getHttpManagement().setKeystore(ksCopy.toAbsolutePath().toString());
config.getHttpManagement().setTruststore(tsCopy.toAbsolutePath().toString());
HttpManagementConnectionFactory connectionFactory = new HttpManagementConnectionFactory(context, null);
HttpManagementConnectionFactory spy = spy(connectionFactory);
spy.createSslWatcher();

verify(spy, Mockito.timeout(1000)).clearHttpConnections();

// Modify filepaths
Files.delete(ksCopy);

// We need 3 invocations, because we can't spy the original constructor call to the clearHttpConnections() and
// as such need to create more SslWatchers() for the same path
verify(spy, Mockito.timeout(30000).atLeast(2)).clearHttpConnections();
}
}