diff --git a/src/packaging/bin/spreaper b/src/packaging/bin/spreaper index 8d1bebad9..f5b7ee671 100755 --- a/src/packaging/bin/spreaper +++ b/src/packaging/bin/spreaper @@ -189,6 +189,9 @@ def _arguments_for_add_cluster(parser): parser.add_argument("jmx_port", help="the JMX port of the Cassandra cluster to be registered", default="7199") parser.add_argument("--jmx-username", help="JMX username in case authentication is activated", default=None) parser.add_argument("--jmx-password", help="JMX password in case authentication is activated", default=None) + parser.add_argument("--truststore-name", + help="Name of the folder in Reaper's truststoreDir to load certs from", + default=None) def _argument_owner(parser): @@ -701,7 +704,8 @@ class ReaperCLI(object): payload = {'seedHost': args.seed_host, 'jmxPort': args.jmx_port, 'jmxUsername': args.jmx_username, - 'jmxPassword': args.jmx_password} + 'jmxPassword': args.jmx_password, + 'truststoreName': args.truststore_name} cluster_data = reaper.postFormData("cluster/auth", payload=payload) printq("# Registration succeeded:") print(json.dumps(json.loads(cluster_data), indent=2, sort_keys=True)) diff --git a/src/server/src/main/docker/Dockerfile b/src/server/src/main/docker/Dockerfile index 11df35ef3..657400faf 100644 --- a/src/server/src/main/docker/Dockerfile +++ b/src/server/src/main/docker/Dockerfile @@ -84,6 +84,7 @@ ENV REAPER_SEGMENT_COUNT_PER_NODE=64 \ REAPER_HTTP_MANAGEMENT_ENABLE="false" \ REAPER_HTTP_MANAGEMENT_KEYSTORE_PATH="" \ REAPER_HTTP_MANAGEMENT_TRUSTSTORE_PATH="" \ + REAPER_HTTP_MANAGEMENT_TRUSTSTORES_DIR="" \ REAPER_TMP_DIRECTORY="/var/tmp/cassandra-reaper" \ REAPER_MEMORY_STORAGE_DIRECTORY="/var/lib/cassandra-reaper/storage" diff --git a/src/server/src/main/docker/cassandra-reaper.yml b/src/server/src/main/docker/cassandra-reaper.yml index 5836e445c..93283fc4b 100644 --- a/src/server/src/main/docker/cassandra-reaper.yml +++ b/src/server/src/main/docker/cassandra-reaper.yml @@ -86,4 +86,5 @@ httpManagement: mgmtApiMetricsPort: ${REAPER_MGMT_API_METRICS_PORT} keystore: ${REAPER_HTTP_MANAGEMENT_KEYSTORE_PATH} truststore: ${REAPER_HTTP_MANAGEMENT_TRUSTSTORE_PATH} + truststoreDir: ${REAPER_HTTP_MANAGEMENT_TRUSTSTORES_DIR} diff --git a/src/server/src/main/java/io/cassandrareaper/ReaperApplication.java b/src/server/src/main/java/io/cassandrareaper/ReaperApplication.java index 110bc844f..705f0f4f4 100644 --- a/src/server/src/main/java/io/cassandrareaper/ReaperApplication.java +++ b/src/server/src/main/java/io/cassandrareaper/ReaperApplication.java @@ -342,7 +342,7 @@ private void maybeInitializeSidecarMode(ClusterResource addClusterResource) thro private boolean selfRegisterClusterForSidecar(ClusterResource addClusterResource, String seedHost) throws ReaperException { final Optional cluster = addClusterResource.findClusterWithSeedHost(seedHost, Optional.empty(), - Optional.empty()); + Optional.empty(), Optional.empty()); if (!cluster.isPresent()) { return false; } diff --git a/src/server/src/main/java/io/cassandrareaper/ReaperApplicationConfiguration.java b/src/server/src/main/java/io/cassandrareaper/ReaperApplicationConfiguration.java index 45a004906..d291c1042 100644 --- a/src/server/src/main/java/io/cassandrareaper/ReaperApplicationConfiguration.java +++ b/src/server/src/main/java/io/cassandrareaper/ReaperApplicationConfiguration.java @@ -753,6 +753,9 @@ public static final class HttpManagement { @JsonProperty private String truststore; + @JsonProperty + private String truststoreDir; + @JsonProperty private Integer mgmtApiMetricsPort; @@ -772,6 +775,10 @@ public String getTruststore() { return truststore; } + public String getTruststoreDir() { + return truststoreDir; + } + @VisibleForTesting public void setEnabled(Boolean enabled) { this.enabled = enabled; @@ -787,6 +794,11 @@ public void setTruststore(String truststore) { this.truststore = truststore; } + @VisibleForTesting + public void setTruststoreDir(String truststoreDir) { + this.truststoreDir = truststoreDir; + } + public int getMgmtApiMetricsPort() { return mgmtApiMetricsPort == null ? DEFAULT_MGMT_API_METRICS_PORT : mgmtApiMetricsPort; } diff --git a/src/server/src/main/java/io/cassandrareaper/core/Cluster.java b/src/server/src/main/java/io/cassandrareaper/core/Cluster.java index 7976b5d16..512a8c367 100644 --- a/src/server/src/main/java/io/cassandrareaper/core/Cluster.java +++ b/src/server/src/main/java/io/cassandrareaper/core/Cluster.java @@ -196,6 +196,12 @@ public Builder withJmxCredentials(JmxCredentials jmxCredentials) { return this; } + public Builder withTruststoreName(String truststoreName) { + Preconditions.checkNotNull(truststoreName); + this.properties.withTruststoreName(truststoreName); + return this; + } + public Cluster build() { Preconditions.checkNotNull(name); Preconditions.checkNotNull(seedHosts); diff --git a/src/server/src/main/java/io/cassandrareaper/core/ClusterProperties.java b/src/server/src/main/java/io/cassandrareaper/core/ClusterProperties.java index a749fa18f..c456d6d9f 100644 --- a/src/server/src/main/java/io/cassandrareaper/core/ClusterProperties.java +++ b/src/server/src/main/java/io/cassandrareaper/core/ClusterProperties.java @@ -25,9 +25,12 @@ public final class ClusterProperties { private final int jmxPort; private final JmxCredentials jmxCredentials; + private final String trustStoreName; + private ClusterProperties(Builder builder) { this.jmxPort = builder.jmxPort; this.jmxCredentials = builder.jmxCredentials; + this.trustStoreName = builder.truststoreName; } public int getJmxPort() { @@ -38,6 +41,10 @@ public JmxCredentials getJmxCredentials() { return jmxCredentials; } + public String getTruststoreName() { + return trustStoreName; + } + public static Builder builder() { return new Builder(); } @@ -47,6 +54,8 @@ public static final class Builder { private int jmxPort; private JmxCredentials jmxCredentials; + private String truststoreName; + private Builder() {} public Builder withJmxPort(int jmxPort) { @@ -59,6 +68,11 @@ public Builder withJmxCredentials(JmxCredentials jmxCredentials) { return this; } + public Builder withTruststoreName(String truststoreName) { + this.truststoreName = truststoreName; + return this; + } + public ClusterProperties build() { return new ClusterProperties(this); } diff --git a/src/server/src/main/java/io/cassandrareaper/management/ClusterFacade.java b/src/server/src/main/java/io/cassandrareaper/management/ClusterFacade.java index 2745008f7..71aac259b 100644 --- a/src/server/src/main/java/io/cassandrareaper/management/ClusterFacade.java +++ b/src/server/src/main/java/io/cassandrareaper/management/ClusterFacade.java @@ -893,12 +893,10 @@ public ICassandraManagementProxy connect(Node node, Collection endpoints private ICassandraManagementProxy connectImpl(Cluster cluster, Collection endpoints) throws ReaperException { try { - ICassandraManagementProxy proxy = context.managementConnectionFactory - .connectAny( - endpoints - .stream() - .map(host -> Node.builder().withCluster(cluster).withHostname(host).build()) - .collect(Collectors.toList())); + ICassandraManagementProxy proxy = context.managementConnectionFactory.connectAny(endpoints.stream() + .map(host -> Node.builder().withCluster(cluster).withHostname(host).build()) + .collect(Collectors.toList()) + ); Async.markClusterActive(cluster, context); return proxy; diff --git a/src/server/src/main/java/io/cassandrareaper/management/http/HttpManagementConnectionFactory.java b/src/server/src/main/java/io/cassandrareaper/management/http/HttpManagementConnectionFactory.java index 0925a8788..bf0251783 100644 --- a/src/server/src/main/java/io/cassandrareaper/management/http/HttpManagementConnectionFactory.java +++ b/src/server/src/main/java/io/cassandrareaper/management/http/HttpManagementConnectionFactory.java @@ -48,6 +48,7 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutorService; @@ -77,6 +78,11 @@ public class HttpManagementConnectionFactory implements IManagementConnectionFactory { private static final char[] KEYSTORE_PASSWORD = "changeit".toCharArray(); + + private static final String KEYSTORE_COMPONENT_NAME = "keystore.jks"; + + private static final String TRUSTSTORE_COMPONENT_NAME = "truststore.jks"; + private static final Logger LOG = LoggerFactory.getLogger(HttpManagementConnectionFactory.class); private static final ConcurrentMap HTTP_CONNECTIONS = Maps.newConcurrentMap(); private final MetricRegistry metricRegistry; @@ -95,13 +101,18 @@ 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); - } + + String ts = context.config.getHttpManagement().getTruststore(); + boolean watchTruststore = ts != null && !ts.isEmpty(); + String ks = context.config.getHttpManagement().getKeystore(); + boolean watchKeystore = ks != null && !ks.isEmpty(); + String tsd = context.config.getHttpManagement().getTruststoreDir(); + boolean watchTruststoreDir = tsd != null && !tsd.isEmpty(); + + try { + createSslWatcher(watchTruststore, watchKeystore, watchTruststoreDir); + } catch (IOException e) { + throw new RuntimeException(e); } } @@ -175,14 +186,18 @@ public HttpCassandraManagementProxy apply(@Nullable String hostName) { OkHttpClient.Builder clientBuilder = new OkHttpClient().newBuilder(); String protocol = "http"; + + Path truststoreName = getTruststoreComponentPath(node, TRUSTSTORE_COMPONENT_NAME); + Path keystoreName = getTruststoreComponentPath(node, KEYSTORE_COMPONENT_NAME); + if (useMtls) { LOG.debug("Using TLS connection to " + node.getHostname()); // We have to split TrustManagers to its own function to please OkHttpClient TrustManager[] trustManagers; SSLContext sslContext; try { - trustManagers = getTrustManagers(); - sslContext = createSslContext(trustManagers); + trustManagers = getTrustManagers(truststoreName); + sslContext = createSslContext(trustManagers, keystoreName); } catch (ReaperException e) { LOG.error("Failed to create SSLContext: " + e.getLocalizedMessage(), e); throw new RuntimeException(e); @@ -218,8 +233,7 @@ public HttpCassandraManagementProxy apply(@Nullable String hostName) { } @VisibleForTesting - SSLContext createSslContext(TrustManager[] tms) throws ReaperException { - Path keyStorePath = Paths.get(config.getHttpManagement().getKeystore()); + SSLContext createSslContext(TrustManager[] tms, Path keyStorePath) throws ReaperException { try (InputStream ksIs = Files.newInputStream(keyStorePath, StandardOpenOption.READ)) { @@ -238,8 +252,11 @@ SSLContext createSslContext(TrustManager[] tms) throws ReaperException { } } - private TrustManager[] getTrustManagers() throws ReaperException { - Path trustStorePath = Paths.get(config.getHttpManagement().getTruststore()); + @VisibleForTesting + TrustManager[] getTrustManagers(Path trustStorePath) throws ReaperException { + + LOG.trace(String.format("Calling getSingleTrustManager with %s", trustStorePath)); + try (InputStream tsIs = Files.newInputStream(trustStorePath, StandardOpenOption.READ)) { KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); trustStore.load(tsIs, KEYSTORE_PASSWORD); @@ -249,30 +266,68 @@ private TrustManager[] getTrustManagers() throws ReaperException { return tmf.getTrustManagers(); } catch (IOException | NoSuchAlgorithmException | KeyStoreException | CertificateException e) { - throw new ReaperException(e); + throw new ReaperException("Error loading trust managers"); } } @VisibleForTesting - void createSslWatcher() throws IOException { + Path getTruststoreComponentPath(Node node, String truststoreComponentName) { + Path trustStorePath; + + // using a .map() instead of .get() to nicely handle absence + Optional truststoreName = node.getCluster().map(c -> c.getProperties().getTruststoreName()); + + if (truststoreName.isPresent()) { + // load a cluster-specific trust store which is a sub-dir of dir pointed to by truststoreDir + Path storesRootPath = Paths.get(config.getHttpManagement().getTruststoreDir()); + trustStorePath = storesRootPath + .resolve(truststoreName.get()) + .resolve(truststoreComponentName) + .toAbsolutePath(); + } else { + // load the generic trust store from truststore/keystore options + trustStorePath = truststoreComponentName.equals(TRUSTSTORE_COMPONENT_NAME) + ? Paths.get(config.getHttpManagement().getTruststore()).toAbsolutePath() + : Paths.get(config.getHttpManagement().getKeystore()).toAbsolutePath(); + } + + return trustStorePath; + } + + @VisibleForTesting + void createSslWatcher(boolean watchTruststore, boolean watchKeystore, boolean watchTruststoreDir) 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( + + Path trustStorePath = watchTruststore ? Paths.get(config.getHttpManagement().getTruststore()) : null; + Path keyStorePath = watchKeystore ? Paths.get(config.getHttpManagement().getKeystore()) : null ; + Path truststoreDirPath = watchTruststoreDir ? Paths.get(config.getHttpManagement().getTruststoreDir()) : null ; + + if (watchKeystore) { + keyStorePath.getParent().register( watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE, - StandardWatchEventKinds.ENTRY_MODIFY); + StandardWatchEventKinds.ENTRY_MODIFY + ); + } + if (watchTruststore && watchKeystore) { + if (!trustStorePath.getParent().equals(keyStorePath.getParent())) { + trustStorePath.getParent().register( + watchService, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_DELETE, + StandardWatchEventKinds.ENTRY_MODIFY + ); + } + } + if (watchTruststoreDir) { + truststoreDirPath.register( + watchService, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_DELETE, + StandardWatchEventKinds.ENTRY_MODIFY + ); } ExecutorService executorService = Executors.newSingleThreadExecutor(); @@ -290,11 +345,23 @@ void createSslWatcher() throws IOException { WatchEvent ev = (WatchEvent) 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 (watchKeystore) { + if (keyStorePath.getParent().resolve(eventFilename).equals(keyStorePath)) { + reloadNeeded = true; + } } + if (watchTruststore) { + if (trustStorePath.getParent().resolve(eventFilename).equals(trustStorePath)) { + // Something in the TLS has been modified.. recreate HTTP connections + reloadNeeded = true; + } + } + if (watchTruststoreDir) { + if (Files.exists(truststoreDirPath.resolve(eventFilename))) { + reloadNeeded = true; + } + } + } if (!key.reset()) { // The watched directories have disappeared.. diff --git a/src/server/src/main/java/io/cassandrareaper/resources/ClusterResource.java b/src/server/src/main/java/io/cassandrareaper/resources/ClusterResource.java index 552649329..3542e30e9 100644 --- a/src/server/src/main/java/io/cassandrareaper/resources/ClusterResource.java +++ b/src/server/src/main/java/io/cassandrareaper/resources/ClusterResource.java @@ -220,7 +220,9 @@ public Response addOrUpdateCluster( @QueryParam("jmxPort") Optional jmxPort) { LOG.info("POST addOrUpdateCluster called with seedHost: {}", seedHost.orElse(null)); - return addOrUpdateCluster(uriInfo, Optional.empty(), seedHost, jmxPort, Optional.empty(), Optional.empty()); + return addOrUpdateCluster( + uriInfo, Optional.empty(), seedHost, jmxPort, Optional.empty(), Optional.empty(), Optional.empty() + ); } @POST @@ -230,10 +232,11 @@ public Response addOrUpdateCluster( @FormParam("seedHost") Optional seedHost, @FormParam("jmxPort") Optional jmxPort, @FormParam("jmxUsername") Optional jmxUsername, - @FormParam("jmxPassword") Optional jmxPassword) { + @FormParam("jmxPassword") Optional jmxPassword, + @FormParam("truststoreName") Optional trustStoreName) { LOG.info("POST addOrUpdateCluster called with seedHost: {}", seedHost.orElse(null)); - return addOrUpdateCluster(uriInfo, Optional.empty(), seedHost, jmxPort, jmxUsername, jmxPassword); + return addOrUpdateCluster(uriInfo, Optional.empty(), seedHost, jmxPort, jmxUsername, jmxPassword, trustStoreName); } @PUT @@ -242,13 +245,16 @@ public Response addOrUpdateCluster( @Context UriInfo uriInfo, @PathParam("cluster_name") String clusterName, @QueryParam("seedHost") Optional seedHost, - @QueryParam("jmxPort") Optional jmxPort) { + @QueryParam("jmxPort") Optional jmxPort, + @QueryParam("truststoreName") Optional truststoreName) { LOG.info( "PUT addOrUpdateCluster called with: cluster_name = {}, seedHost = {}", clusterName, seedHost.orElse(null)); - return addOrUpdateCluster(uriInfo, Optional.of(clusterName), seedHost, jmxPort, Optional.empty(), Optional.empty()); + return addOrUpdateCluster( + uriInfo, Optional.of(clusterName), seedHost, jmxPort, Optional.empty(), Optional.empty(), truststoreName + ); } @PUT @@ -259,13 +265,16 @@ public Response addOrUpdateCluster( @FormParam("seedHost") Optional seedHost, @FormParam("jmxPort") Optional jmxPort, @FormParam("jmxUsername") Optional jmxUsername, - @FormParam("jmxPassword") Optional jmxPassword) { + @FormParam("jmxPassword") Optional jmxPassword, + @FormParam("truststoreName") Optional trustStoreName) { LOG.info( "PUT addOrUpdateCluster called with: cluster_name = {}, seedHost = {}", clusterName, seedHost.orElse(null)); - return addOrUpdateCluster(uriInfo, Optional.of(clusterName), seedHost, jmxPort, jmxUsername, jmxPassword); + return addOrUpdateCluster( + uriInfo, Optional.of(clusterName), seedHost, jmxPort, jmxUsername, jmxPassword, trustStoreName + ); } private Response addOrUpdateCluster( @@ -274,7 +283,8 @@ private Response addOrUpdateCluster( Optional seedHost, Optional jmxPort, Optional jmxUsername, - Optional jmxPassword) { + Optional jmxPassword, + Optional truststoreName) { if (!seedHost.isPresent()) { LOG.error("POST/PUT on cluster resource {} called without seedHost", clusterName.orElse(null)); @@ -297,8 +307,9 @@ private Response addOrUpdateCluster( } } - final Optional cluster = findClusterWithSeedHost(seedHost.get(), jmxPort, - Optional.ofNullable(jmxCredentials)); + final Optional cluster = findClusterWithSeedHost( + seedHost.get(), jmxPort, Optional.ofNullable(jmxCredentials), truststoreName + ); if (!cluster.isPresent()) { return Response .status(Response.Status.BAD_REQUEST) @@ -361,9 +372,12 @@ private Response addOrUpdateCluster( return Response.created(location).build(); } - public Optional findClusterWithSeedHost(String seedHost, - Optional jmxPort, - Optional jmxCredentials) { + public Optional findClusterWithSeedHost( + String seedHost, + Optional jmxPort, + Optional jmxCredentials, + Optional truststoreName + ) { Set seedHosts = parseSeedHosts(seedHost); try { Cluster.Builder clusterBuilder = Cluster.builder() @@ -371,6 +385,7 @@ public Optional findClusterWithSeedHost(String seedHost, .withSeedHosts(ImmutableSet.of(seedHost)) .withJmxPort(jmxPort.orElse(Cluster.DEFAULT_JMX_PORT)); jmxCredentials.ifPresent(clusterBuilder::withJmxCredentials); + truststoreName.ifPresent(clusterBuilder::withTruststoreName); Cluster cluster = clusterBuilder.build(); String clusterName = clusterFacade.getClusterName(cluster, seedHosts); @@ -390,6 +405,7 @@ public Optional findClusterWithSeedHost(String seedHost, .withState(Cluster.State.ACTIVE) .withLastContact(LocalDate.now()); jmxCredentials.ifPresent(clusterBuilder::withJmxCredentials); + truststoreName.ifPresent(clusterBuilder::withTruststoreName); return Optional.of(clusterBuilder.build()); } catch (ReaperException e) { LOG.error("failed to find cluster with seed hosts: {}", seedHosts, e); diff --git a/src/server/src/test/java/io/cassandrareaper/management/http/HttpCassandraManagementProxyTest.java b/src/server/src/test/java/io/cassandrareaper/management/http/HttpCassandraManagementProxyTest.java index 19f1ea7a7..a8306a864 100644 --- a/src/server/src/test/java/io/cassandrareaper/management/http/HttpCassandraManagementProxyTest.java +++ b/src/server/src/test/java/io/cassandrareaper/management/http/HttpCassandraManagementProxyTest.java @@ -20,6 +20,7 @@ import io.cassandrareaper.AppContext; import io.cassandrareaper.ReaperApplicationConfiguration; import io.cassandrareaper.ReaperException; +import io.cassandrareaper.core.Cluster; import io.cassandrareaper.core.GenericMetric; import io.cassandrareaper.core.Node; import io.cassandrareaper.core.RepairType; @@ -31,9 +32,9 @@ import java.math.BigInteger; import java.net.InetSocketAddress; -import java.nio.file.FileSystems; 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; @@ -59,6 +60,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.Lists; import com.google.common.util.concurrent.MoreExecutors; import org.apache.cassandra.repair.RepairParallelism; @@ -817,11 +819,11 @@ public void testSSLHotReload() throws Exception { context.config = config; Path tempDirectory = Files.createTempDirectory("reload-test"); - // handle running tests locally vs. via GitHub actions - // 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"); - // use relative paths as we are likely running locally - Path projectRoot = FileSystems.getDefault().getPath("..", "..", ".."); + + Path projectRoot = Paths.get(".").toAbsolutePath(); + while (!projectRoot.endsWith("cassandra-reaper")) { + projectRoot = projectRoot.getParent(); + } Path ks = projectRoot.resolve(".github/files/keystore.jks"); Path ts = projectRoot.resolve(".github/files/truststore.jks"); @@ -833,17 +835,100 @@ public void testSSLHotReload() throws Exception { config.getHttpManagement().setEnabled(true); config.getHttpManagement().setKeystore(ksCopy.toAbsolutePath().toString()); config.getHttpManagement().setTruststore(tsCopy.toAbsolutePath().toString()); + + Path storesRoot = Files.createDirectory(tempDirectory.resolve("stores")); + Path clustersStore = Files.createDirectory(storesRoot.resolve("cluster1")); + Files.copy(ts, clustersStore.resolve(ts.getFileName())); + Files.copy(ks, clustersStore.resolve(ks.getFileName())); + config.getHttpManagement().setTruststoreDir(storesRoot.toAbsolutePath().toString()); + HttpManagementConnectionFactory connectionFactory = new HttpManagementConnectionFactory(context, null); HttpManagementConnectionFactory spy = spy(connectionFactory); - spy.createSslWatcher(); + spy.createSslWatcher(true, true, true); verify(spy, Mockito.timeout(1000)).clearHttpConnections(); // Modify filepaths Files.delete(ksCopy); + Files.delete(clustersStore.resolve(ts.getFileName())); - // We need 3 invocations, because we can't spy the original constructor call to the clearHttpConnections() and + // We need 4 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(); + verify(spy, Mockito.timeout(30000).atLeast(4)).clearHttpConnections(); } + + @Test + public void testGetTruststore() throws Exception { + + Path projectRoot = Paths.get(".").toAbsolutePath(); + while (!projectRoot.endsWith("cassandra-reaper")) { + projectRoot = projectRoot.getParent(); + } + Path ks = projectRoot.resolve(".github/files/keystore.jks"); + Path ts = projectRoot.resolve(".github/files/truststore.jks"); + + Path tempDirectory = Files.createTempDirectory("get-truststore-test"); + Files.copy(ks, tempDirectory.resolve("keystore.jks")); + Files.copy(ts, tempDirectory.resolve("truststore.jks")); + + Path perClusterStores = tempDirectory.resolve(Paths.get("perClusterStores")); + Files.createDirectory(perClusterStores.toAbsolutePath()); + Path clustersStores = perClusterStores.resolve("testClusterTruststore"); + Files.createDirectory(clustersStores); + Files.copy(ks, clustersStores.resolve("keystore.jks")); + Files.copy(ts, clustersStores .resolve("trustore.jks")); + + ReaperApplicationConfiguration config = new ReaperApplicationConfiguration(); + config.getHttpManagement().setTruststoreDir(perClusterStores.toAbsolutePath().toString()); + + String tempTrustStore = tempDirectory.resolve("truststore.jks").toAbsolutePath().toString(); + String tempKeyStore = tempDirectory.resolve("keystore.jks").toAbsolutePath().toString(); + config.getHttpManagement().setTruststore(tempTrustStore); + config.getHttpManagement().setKeystore(tempKeyStore); + + AppContext context = mock(AppContext.class); + context.config = config; + HttpManagementConnectionFactory connectionFactory = new HttpManagementConnectionFactory(context, null); + + // for a cluster that features truststoreName, we try to get its specific stores + Cluster clusterWithTruststore = Cluster.builder() + .withName("testCluster") + .withSeedHosts(ImmutableSet.of("testHost")) + .withTruststoreName("testClusterTruststore") + .build(); + Node node = Node.builder() + .withHostname("testHost") + .withCluster(clusterWithTruststore) + .build(); + + Path expected = tempDirectory + .resolve("perClusterStores") + .resolve("testClusterTruststore") + .resolve("truststore.jks"); + Path actual = connectionFactory.getTruststoreComponentPath(node, "truststore.jks"); + assertEquals(expected, actual); + + expected = tempDirectory.resolve("perClusterStores").resolve("testClusterTruststore").resolve("keystore.jks"); + actual = connectionFactory.getTruststoreComponentPath(node, "keystore.jks"); + assertEquals(expected, actual); + + // but if the cluster doesn't ask for specific keystore, we use general one + Cluster clusterWithoutTruststore = Cluster.builder() + .withName("testCluster") + .withSeedHosts(ImmutableSet.of("testHost")) + .build(); + node = Node.builder() + .withHostname("testHost") + .withCluster(clusterWithoutTruststore) + .build(); + + expected = tempDirectory.resolve("truststore.jks"); + actual = connectionFactory.getTruststoreComponentPath(node, "truststore.jks"); + assertEquals(expected, actual); + + expected = tempDirectory.resolve("keystore.jks"); + actual = connectionFactory.getTruststoreComponentPath(node, "keystore.jks"); + assertEquals(expected, actual); + } + } diff --git a/src/server/src/test/java/io/cassandrareaper/resources/ClusterResourceTest.java b/src/server/src/test/java/io/cassandrareaper/resources/ClusterResourceTest.java index df472e229..41262b56d 100644 --- a/src/server/src/test/java/io/cassandrareaper/resources/ClusterResourceTest.java +++ b/src/server/src/test/java/io/cassandrareaper/resources/ClusterResourceTest.java @@ -69,6 +69,8 @@ public final class ClusterResourceTest { static final String JMX_USERNAME = "foo"; static final String JMX_PASSWORD = "bar"; + static final String TRUSTSTORE_NAME = "baz"; + private static final String STCS = "SizeTieredCompactionStrategy"; @Test @@ -83,7 +85,8 @@ public void testAddCluster() throws Exception { .addOrUpdateCluster(mocks.uriInfo, Optional.of(SEED_HOST), Optional.of(Cluster.DEFAULT_JMX_PORT), Optional.of(JMX_USERNAME), - Optional.of(JMX_PASSWORD)); + Optional.of(JMX_PASSWORD), + Optional.of(TRUSTSTORE_NAME)); Assertions.assertThat(response.getStatus()).isEqualTo(HttpStatus.CREATED_201); assertEquals(1, mocks.context.storage.getClusterDao().getClusters().size()); @@ -122,7 +125,8 @@ public void testAddExistingCluster() throws Exception { .addOrUpdateCluster(mocks.uriInfo, Optional.of(SEED_HOST), Optional.of(Cluster.DEFAULT_JMX_PORT), Optional.of(JMX_USERNAME), - Optional.of(JMX_PASSWORD)); + Optional.of(JMX_PASSWORD), + Optional.of(TRUSTSTORE_NAME)); Assertions.assertThat(response.getStatus()).isEqualTo(HttpStatus.NO_CONTENT_204); assertTrue(response.getLocation().toString().endsWith("/cluster/" + cluster.getName())); @@ -161,7 +165,8 @@ public void testAddExistingClusterWithClusterName() throws Exception { Optional.of(SEED_HOST), Optional.of(Cluster.DEFAULT_JMX_PORT), Optional.of(JMX_USERNAME), - Optional.of(JMX_PASSWORD)); + Optional.of(JMX_PASSWORD), + Optional.of(TRUSTSTORE_NAME)); Assertions.assertThat(response.getStatus()).isEqualTo(HttpStatus.NO_CONTENT_204); assertTrue(response.getLocation().toString().endsWith("/cluster/" + cluster.getName())); @@ -190,7 +195,8 @@ public void testFailAddingJmxCredentialsWithoutEncryptionConfigured() throws Exc Optional.of(SEED_HOST), Optional.of(Cluster.DEFAULT_JMX_PORT), Optional.of(JMX_USERNAME), - Optional.of(JMX_PASSWORD)); + Optional.of(JMX_PASSWORD), + Optional.of(TRUSTSTORE_NAME)); assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); } @@ -471,15 +477,24 @@ public void testModifyClusterSeeds() throws ReaperException { ClusterResource clusterResource = ClusterResource.create(mocks.context, mocks.cryptograph, mocks.context.storage.getEventsDao(), mocks.context.storage.getRepairRunDao()); - clusterResource.addOrUpdateCluster(mocks.uriInfo, Optional.of(SEED_HOST), Optional.of(Cluster.DEFAULT_JMX_PORT), - Optional.of(JMX_USERNAME), Optional.of(JMX_PASSWORD)); + + clusterResource.addOrUpdateCluster( + mocks.uriInfo, + Optional.of(SEED_HOST), + Optional.of(Cluster.DEFAULT_JMX_PORT), + Optional.of(JMX_USERNAME), + Optional.of(JMX_PASSWORD), + Optional.of(TRUSTSTORE_NAME) + ); + doReturn(Arrays.asList(SEED_HOST + 1, SEED_HOST)).when(mocks.cassandraManagementProxy).getLiveNodes(); Response response = clusterResource .addOrUpdateCluster(mocks.uriInfo, Optional.of(SEED_HOST + 1), Optional.of(Cluster.DEFAULT_JMX_PORT), Optional.of(JMX_USERNAME), - Optional.of(JMX_PASSWORD)); + Optional.of(JMX_PASSWORD), + Optional.of(TRUSTSTORE_NAME)); assertEquals(HttpStatus.OK_200, response.getStatus()); assertTrue(response.getLocation().toString().endsWith("/cluster/" + CLUSTER_NAME)); @@ -495,7 +510,9 @@ public void testModifyClusterSeeds() throws ReaperException { Optional.of(SEED_HOST + 1), Optional.of(Cluster.DEFAULT_JMX_PORT), Optional.of(JMX_USERNAME), - Optional.of(JMX_PASSWORD)); + Optional.of(JMX_PASSWORD), + Optional.of(TRUSTSTORE_NAME) + ); assertEquals(HttpStatus.NO_CONTENT_204, response.getStatus()); assertTrue(response.getLocation().toString().endsWith("/cluster/" + cluster.getName())); @@ -510,8 +527,15 @@ public void testModifyClusterSeedsWithClusterName() throws ReaperException { mocks.context.storage.getEventsDao(), mocks.context.storage.getRepairRunDao()); ; - clusterResource.addOrUpdateCluster(mocks.uriInfo, Optional.of(SEED_HOST), Optional.of(Cluster.DEFAULT_JMX_PORT), - Optional.of(JMX_USERNAME), Optional.of(JMX_PASSWORD)); + clusterResource.addOrUpdateCluster( + mocks.uriInfo, + Optional.of(SEED_HOST), + Optional.of(Cluster.DEFAULT_JMX_PORT), + Optional.of(JMX_USERNAME), + Optional.of(JMX_PASSWORD), + Optional.of(TRUSTSTORE_NAME) + ); + doReturn(Arrays.asList(SEED_HOST + 1, SEED_HOST)).when(mocks.cassandraManagementProxy).getLiveNodes(); Response response = clusterResource.addOrUpdateCluster( @@ -520,7 +544,8 @@ public void testModifyClusterSeedsWithClusterName() throws ReaperException { Optional.of(SEED_HOST + 1), Optional.of(Cluster.DEFAULT_JMX_PORT), Optional.of(JMX_USERNAME), - Optional.of(JMX_PASSWORD)); + Optional.of(JMX_PASSWORD), + Optional.of(TRUSTSTORE_NAME)); assertEquals(HttpStatus.OK_200, response.getStatus()); assertTrue(response.getLocation().toString().endsWith("/cluster/" + CLUSTER_NAME)); @@ -536,7 +561,8 @@ public void testModifyClusterSeedsWithClusterName() throws ReaperException { Optional.of(SEED_HOST + 1), Optional.of(Cluster.DEFAULT_JMX_PORT), Optional.of(JMX_USERNAME), - Optional.of(JMX_PASSWORD)); + Optional.of(JMX_PASSWORD), + Optional.of(TRUSTSTORE_NAME)); assertEquals(HttpStatus.NO_CONTENT_204, response.getStatus()); assertTrue(response.getLocation().toString().endsWith("/cluster/" + cluster.getName())); @@ -565,9 +591,14 @@ public void addingAClusterAutomaticallySetupSchedulingRepairsWhenEnabled() throw mocks.context.storage.getRepairRunDao()); ; - Response response = clusterResource - .addOrUpdateCluster(mocks.uriInfo, Optional.of(SEED_HOST), Optional.of(Cluster.DEFAULT_JMX_PORT), - Optional.of(JMX_USERNAME), Optional.of(JMX_PASSWORD)); + Response response = clusterResource.addOrUpdateCluster( + mocks.uriInfo, + Optional.of(SEED_HOST), + Optional.of(Cluster.DEFAULT_JMX_PORT), + Optional.of(JMX_USERNAME), + Optional.of(JMX_PASSWORD), + Optional.of(TRUSTSTORE_NAME) + ); assertEquals(HttpStatus.CREATED_201, response.getStatus()); assertEquals(1, mocks.context.storage.getRepairScheduleDao().getAllRepairSchedules().size()); @@ -599,9 +630,14 @@ public void testClusterDeleting() throws Exception { mocks.context.storage.getRepairRunDao()); ; - Response response = clusterResource - .addOrUpdateCluster(mocks.uriInfo, Optional.of(SEED_HOST), Optional.of(Cluster.DEFAULT_JMX_PORT), - Optional.of(JMX_USERNAME), Optional.of(JMX_PASSWORD)); + Response response = clusterResource.addOrUpdateCluster( + mocks.uriInfo, + Optional.of(SEED_HOST), + Optional.of(Cluster.DEFAULT_JMX_PORT), + Optional.of(JMX_USERNAME), + Optional.of(JMX_PASSWORD), + Optional.of(TRUSTSTORE_NAME) + ); assertEquals(HttpStatus.CREATED_201, response.getStatus()); assertEquals(1, mocks.context.storage.getRepairScheduleDao().getAllRepairSchedules().size());