From 0811ba15c30cd4263d98414ab8bdbbd119306441 Mon Sep 17 00:00:00 2001 From: Eric Giffon Date: Wed, 14 Jun 2023 17:03:47 +0200 Subject: [PATCH] SONAR-14853 Elasticsearch http encryption --- build.gradle | 1 + server/sonar-main/build.gradle | 1 + .../sonar/application/AppStateFactory.java | 9 ++- .../application/ProcessLauncherImpl.java | 51 +++++++++----- .../sonar/application/es/EsConnectorImpl.java | 46 ++++++++++++- .../sonar/application/es/EsInstallation.java | 21 ++++++ .../sonar/application/es/EsKeyStoreCli.java | 1 + .../org/sonar/application/es/EsSettings.java | 12 +++- .../application/ProcessLauncherImplTest.java | 33 +++++++++ .../application/es/EsConnectorImplTest.java | 57 +++++++++++++++- .../application/es/EsInstallationTest.java | 31 +++++++++ .../sonar/application/es/EsSettingsTest.java | 66 ++++++++++++++++-- .../org/sonar/process/ProcessProperties.java | 2 + server/sonar-server-common/build.gradle | 1 + .../java/org/sonar/server/es/EsClient.java | 47 ++++++++++--- .../org/sonar/server/es/EsClientProvider.java | 16 +++-- .../sonar/server/es/EsClientProviderTest.java | 26 +++++++ .../org/sonar/server/es/EsClientTest.java | 68 ++++++++++++++++--- 18 files changed, 437 insertions(+), 52 deletions(-) diff --git a/build.gradle b/build.gradle index be4171dd36d..aa72c7d4313 100644 --- a/build.gradle +++ b/build.gradle @@ -311,6 +311,7 @@ subprojects { dependencySet(group: 'com.squareup.okhttp3', version: '4.10.0') { entry 'okhttp' entry 'mockwebserver' + entry 'okhttp-tls' } dependency 'org.json:json:20230227' dependency 'com.tngtech.java:junit-dataprovider:1.13.1' diff --git a/server/sonar-main/build.gradle b/server/sonar-main/build.gradle index 0e554b7104d..cb419dbac62 100644 --- a/server/sonar-main/build.gradle +++ b/server/sonar-main/build.gradle @@ -34,6 +34,7 @@ dependencies { testImplementation 'org.awaitility:awaitility' testImplementation 'org.mockito:mockito-core' testImplementation 'com.squareup.okhttp3:mockwebserver' + testImplementation 'com.squareup.okhttp3:okhttp-tls' testImplementation 'commons-logging:commons-logging:1.2' testImplementation project(':sonar-testing-harness') } diff --git a/server/sonar-main/src/main/java/org/sonar/application/AppStateFactory.java b/server/sonar-main/src/main/java/org/sonar/application/AppStateFactory.java index 1d81bcab68e..b61aefbe4de 100644 --- a/server/sonar-main/src/main/java/org/sonar/application/AppStateFactory.java +++ b/server/sonar-main/src/main/java/org/sonar/application/AppStateFactory.java @@ -20,7 +20,10 @@ package org.sonar.application; import com.google.common.net.HostAndPort; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Arrays; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import org.sonar.application.cluster.AppNodesClusterHostsConsistency; @@ -34,6 +37,8 @@ import org.sonar.process.Props; import org.sonar.process.cluster.hz.HazelcastMember; import org.sonar.process.cluster.hz.HazelcastMemberBuilder; +import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_HTTP_KEYSTORE; +import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_HTTP_KEYSTORE_PASSWORD; import static org.sonar.process.ProcessProperties.Property.CLUSTER_HZ_HOSTS; import static org.sonar.process.ProcessProperties.Property.CLUSTER_KUBERNETES; import static org.sonar.process.ProcessProperties.Property.CLUSTER_NODE_HOST; @@ -78,6 +83,8 @@ public class AppStateFactory { .map(HostAndPort::fromString) .collect(Collectors.toSet()); String searchPassword = props.value(CLUSTER_SEARCH_PASSWORD.getKey()); - return new EsConnectorImpl(hostAndPorts, searchPassword); + Path keyStorePath = Optional.ofNullable(props.value(CLUSTER_ES_HTTP_KEYSTORE.getKey())).map(Paths::get).orElse(null); + String keyStorePassword = props.value(CLUSTER_ES_HTTP_KEYSTORE_PASSWORD.getKey()); + return new EsConnectorImpl(hostAndPorts, searchPassword, keyStorePath, keyStorePassword); } } diff --git a/server/sonar-main/src/main/java/org/sonar/application/ProcessLauncherImpl.java b/server/sonar-main/src/main/java/org/sonar/application/ProcessLauncherImpl.java index f8c2952070e..06c95efaff9 100644 --- a/server/sonar-main/src/main/java/org/sonar/application/ProcessLauncherImpl.java +++ b/server/sonar-main/src/main/java/org/sonar/application/ProcessLauncherImpl.java @@ -54,6 +54,7 @@ import static com.google.common.base.Preconditions.checkArgument; import static java.lang.String.format; import static java.util.Collections.singleton; import static org.sonar.application.es.EsKeyStoreCli.BOOTSTRAP_PASSWORD_PROPERTY_KEY; +import static org.sonar.application.es.EsKeyStoreCli.HTTP_KEYSTORE_PASSWORD_PROPERTY_KEY; import static org.sonar.application.es.EsKeyStoreCli.KEYSTORE_PASSWORD_PROPERTY_KEY; import static org.sonar.application.es.EsKeyStoreCli.TRUSTSTORE_PASSWORD_PROPERTY_KEY; import static org.sonar.process.ProcessEntryPoint.PROPERTY_GRACEFUL_STOP_TIMEOUT_MS; @@ -102,7 +103,8 @@ public class ProcessLauncherImpl implements ProcessLauncher { if (processId == ProcessId.ELASTICSEARCH) { checkArgument(esInstallation != null, "Incorrect configuration EsInstallation is null"); EsConnectorImpl esConnector = new EsConnectorImpl(singleton(HostAndPort.fromParts(esInstallation.getHost(), - esInstallation.getHttpPort())), esInstallation.getBootstrapPassword()); + esInstallation.getHttpPort())), esInstallation.getBootstrapPassword(), esInstallation.getHttpKeyStoreLocation(), + esInstallation.getHttpKeyStorePassword().orElse(null)); return new EsManagedProcess(process, processId, esConnector); } else { ProcessCommands commands = allProcessesCommands.createAfterClean(processId.getIpcIndex()); @@ -140,7 +142,7 @@ public class ProcessLauncherImpl implements ProcessLauncher { pruneElasticsearchConfDirectory(confDir); createElasticsearchConfDirectory(confDir); - setupElasticsearchAuthentication(esInstallation); + setupElasticsearchSecurity(esInstallation); esInstallation.getEsYmlSettings().writeToYmlSettingsFile(esInstallation.getElasticsearchYml()); esInstallation.getEsJvmOptions().writeToJvmOptionFile(esInstallation.getJvmOptions()); @@ -163,26 +165,41 @@ public class ProcessLauncherImpl implements ProcessLauncher { } } - private void setupElasticsearchAuthentication(EsInstallation esInstallation) { + private void setupElasticsearchSecurity(EsInstallation esInstallation) { if (esInstallation.isSecurityEnabled()) { - EsKeyStoreCli keyStoreCli = EsKeyStoreCli.getInstance(esInstallation) - .store(BOOTSTRAP_PASSWORD_PROPERTY_KEY, esInstallation.getBootstrapPassword()); + EsKeyStoreCli keyStoreCli = EsKeyStoreCli.getInstance(esInstallation); - String esConfPath = esInstallation.getConfDirectory().getAbsolutePath(); + setupElasticsearchAuthentication(esInstallation, keyStoreCli); + setupElasticsearchHttpEncryption(esInstallation, keyStoreCli); - Path trustStoreLocation = esInstallation.getTrustStoreLocation(); - Path keyStoreLocation = esInstallation.getKeyStoreLocation(); - if (trustStoreLocation.equals(keyStoreLocation)) { - copyFile(trustStoreLocation, Paths.get(esConfPath, trustStoreLocation.toFile().getName())); - } else { - copyFile(trustStoreLocation, Paths.get(esConfPath, trustStoreLocation.toFile().getName())); - copyFile(keyStoreLocation, Paths.get(esConfPath, keyStoreLocation.toFile().getName())); - } + keyStoreCli.executeWith(this::launchJava); + } + } - esInstallation.getTrustStorePassword().ifPresent(s -> keyStoreCli.store(TRUSTSTORE_PASSWORD_PROPERTY_KEY, s)); - esInstallation.getKeyStorePassword().ifPresent(s -> keyStoreCli.store(KEYSTORE_PASSWORD_PROPERTY_KEY, s)); + private static void setupElasticsearchAuthentication(EsInstallation esInstallation, EsKeyStoreCli keyStoreCli) { + keyStoreCli.store(BOOTSTRAP_PASSWORD_PROPERTY_KEY, esInstallation.getBootstrapPassword()); - keyStoreCli.executeWith(this::launchJava); + String esConfPath = esInstallation.getConfDirectory().getAbsolutePath(); + + Path trustStoreLocation = esInstallation.getTrustStoreLocation(); + Path keyStoreLocation = esInstallation.getKeyStoreLocation(); + if (trustStoreLocation.equals(keyStoreLocation)) { + copyFile(trustStoreLocation, Paths.get(esConfPath, trustStoreLocation.toFile().getName())); + } else { + copyFile(trustStoreLocation, Paths.get(esConfPath, trustStoreLocation.toFile().getName())); + copyFile(keyStoreLocation, Paths.get(esConfPath, keyStoreLocation.toFile().getName())); + } + + esInstallation.getTrustStorePassword().ifPresent(s -> keyStoreCli.store(TRUSTSTORE_PASSWORD_PROPERTY_KEY, s)); + esInstallation.getKeyStorePassword().ifPresent(s -> keyStoreCli.store(KEYSTORE_PASSWORD_PROPERTY_KEY, s)); + } + + private static void setupElasticsearchHttpEncryption(EsInstallation esInstallation, EsKeyStoreCli keyStoreCli) { + if (esInstallation.isHttpEncryptionEnabled()) { + String esConfPath = esInstallation.getConfDirectory().getAbsolutePath(); + Path httpKeyStoreLocation = esInstallation.getHttpKeyStoreLocation(); + copyFile(httpKeyStoreLocation, Paths.get(esConfPath, httpKeyStoreLocation.toFile().getName())); + esInstallation.getHttpKeyStorePassword().ifPresent(s -> keyStoreCli.store(HTTP_KEYSTORE_PASSWORD_PROPERTY_KEY, s)); } } diff --git a/server/sonar-main/src/main/java/org/sonar/application/es/EsConnectorImpl.java b/server/sonar-main/src/main/java/org/sonar/application/es/EsConnectorImpl.java index 9906aeba541..d4fe260aa3a 100644 --- a/server/sonar-main/src/main/java/org/sonar/application/es/EsConnectorImpl.java +++ b/server/sonar-main/src/main/java/org/sonar/application/es/EsConnectorImpl.java @@ -21,16 +21,26 @@ package org.sonar.application.es; import com.google.common.net.HostAndPort; import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyStore; import java.util.Arrays; import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import javax.annotation.Nullable; +import javax.net.ssl.SSLContext; import org.apache.http.HttpHost; import org.apache.http.auth.AuthScope; import org.apache.http.auth.UsernamePasswordCredentials; import org.apache.http.impl.client.BasicCredentialsProvider; +import org.apache.http.ssl.SSLContextBuilder; +import org.apache.http.ssl.SSLContexts; import org.elasticsearch.action.admin.cluster.health.ClusterHealthRequest; import org.elasticsearch.action.admin.cluster.health.ClusterHealthResponse; import org.elasticsearch.client.RequestOptions; @@ -51,10 +61,15 @@ public class EsConnectorImpl implements EsConnector { private final AtomicReference restClient = new AtomicReference<>(null); private final Set hostAndPorts; private final String searchPassword; + private final Path keyStorePath; + private final String keyStorePassword; - public EsConnectorImpl(Set hostAndPorts, @Nullable String searchPassword) { + public EsConnectorImpl(Set hostAndPorts, @Nullable String searchPassword, @Nullable Path keyStorePath, + @Nullable String keyStorePassword) { this.hostAndPorts = hostAndPorts; this.searchPassword = searchPassword; + this.keyStorePath = keyStorePath; + this.keyStorePassword = keyStorePassword; } @Override @@ -94,7 +109,7 @@ public class EsConnectorImpl implements EsConnector { private RestHighLevelClient buildRestHighLevelClient() { HttpHost[] httpHosts = hostAndPorts.stream() - .map(hostAndPort -> new HttpHost(hostAndPort.getHost(), hostAndPort.getPortOrDefault(9001))) + .map(this::toHttpHost) .toArray(HttpHost[]::new); if (LOG.isDebugEnabled()) { @@ -110,15 +125,42 @@ public class EsConnectorImpl implements EsConnector { BasicCredentialsProvider provider = getBasicCredentialsProvider(searchPassword); httpClientBuilder.setDefaultCredentialsProvider(provider); } + + if (keyStorePath != null) { + SSLContext sslContext = getSSLContext(keyStorePath, keyStorePassword); + httpClientBuilder.setSSLContext(sslContext); + } + return httpClientBuilder; }); return new RestHighLevelClient(builder); } + private HttpHost toHttpHost(HostAndPort host) { + try { + String scheme = keyStorePath != null ? "https" : HttpHost.DEFAULT_SCHEME_NAME; + return new HttpHost(InetAddress.getByName(host.getHost()), host.getPortOrDefault(9001), scheme); + } catch (UnknownHostException e) { + throw new IllegalStateException("Can not resolve host [" + host + "]", e); + } + } + private static BasicCredentialsProvider getBasicCredentialsProvider(String searchPassword) { BasicCredentialsProvider provider = new BasicCredentialsProvider(); provider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(ES_USERNAME, searchPassword)); return provider; } + private static SSLContext getSSLContext(Path keyStorePath, @Nullable String keyStorePassword) { + try { + KeyStore keyStore = KeyStore.getInstance("pkcs12"); + try (InputStream is = Files.newInputStream(keyStorePath)) { + keyStore.load(is, keyStorePassword == null ? null : keyStorePassword.toCharArray()); + } + SSLContextBuilder sslBuilder = SSLContexts.custom().loadTrustMaterial(keyStore, null); + return sslBuilder.build(); + } catch (IOException | GeneralSecurityException e) { + throw new IllegalStateException("Failed to setup SSL context on ES client", e); + } + } } diff --git a/server/sonar-main/src/main/java/org/sonar/application/es/EsInstallation.java b/server/sonar-main/src/main/java/org/sonar/application/es/EsInstallation.java index ebda731f753..68b21afdc22 100644 --- a/server/sonar-main/src/main/java/org/sonar/application/es/EsInstallation.java +++ b/server/sonar-main/src/main/java/org/sonar/application/es/EsInstallation.java @@ -34,6 +34,8 @@ import org.sonar.core.util.stream.MoreCollectors; import org.sonar.process.Props; import static org.sonar.process.ProcessProperties.Property.CLUSTER_ENABLED; +import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_HTTP_KEYSTORE; +import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_HTTP_KEYSTORE_PASSWORD; import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_KEYSTORE; import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_KEYSTORE_PASSWORD; import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_TRUSTSTORE; @@ -73,6 +75,10 @@ public class EsInstallation { private final String keyStorePassword; @Nullable private final String trustStorePassword; + private final Path httpKeyStoreLocation; + @Nullable + private final String httpKeyStorePassword; + private final boolean httpEncryptionEnabled; public EsInstallation(Props props) { File sqHomeDir = props.nonNullValueAsFile(PATH_HOME.getKey()); @@ -89,6 +95,9 @@ public class EsInstallation { this.keyStorePassword = props.value(CLUSTER_ES_KEYSTORE_PASSWORD.getKey()); this.trustStoreLocation = getPath(props.value(CLUSTER_ES_TRUSTSTORE.getKey())); this.trustStorePassword = props.value(CLUSTER_ES_TRUSTSTORE_PASSWORD.getKey()); + this.httpKeyStoreLocation = getPath(props.value(CLUSTER_ES_HTTP_KEYSTORE.getKey())); + this.httpKeyStorePassword = props.value(CLUSTER_ES_HTTP_KEYSTORE_PASSWORD.getKey()); + this.httpEncryptionEnabled = securityEnabled && httpKeyStoreLocation != null; } private static Path getPath(@Nullable String path) { @@ -224,4 +233,16 @@ public class EsInstallation { public Optional getTrustStorePassword() { return Optional.ofNullable(trustStorePassword); } + + public Path getHttpKeyStoreLocation() { + return httpKeyStoreLocation; + } + + public Optional getHttpKeyStorePassword() { + return Optional.ofNullable(httpKeyStorePassword); + } + + public boolean isHttpEncryptionEnabled() { + return httpEncryptionEnabled; + } } diff --git a/server/sonar-main/src/main/java/org/sonar/application/es/EsKeyStoreCli.java b/server/sonar-main/src/main/java/org/sonar/application/es/EsKeyStoreCli.java index 74514120567..d9daddef78f 100644 --- a/server/sonar-main/src/main/java/org/sonar/application/es/EsKeyStoreCli.java +++ b/server/sonar-main/src/main/java/org/sonar/application/es/EsKeyStoreCli.java @@ -41,6 +41,7 @@ public class EsKeyStoreCli { public static final String BOOTSTRAP_PASSWORD_PROPERTY_KEY = "bootstrap.password"; public static final String KEYSTORE_PASSWORD_PROPERTY_KEY = "xpack.security.transport.ssl.keystore.secure_password"; public static final String TRUSTSTORE_PASSWORD_PROPERTY_KEY = "xpack.security.transport.ssl.truststore.secure_password"; + public static final String HTTP_KEYSTORE_PASSWORD_PROPERTY_KEY = "xpack.security.http.ssl.keystore.secure_password"; private static final String MAIN_CLASS_NAME = "org.elasticsearch.launcher.CliToolLauncher"; diff --git a/server/sonar-main/src/main/java/org/sonar/application/es/EsSettings.java b/server/sonar-main/src/main/java/org/sonar/application/es/EsSettings.java index 843fd1e3585..b438fd88684 100644 --- a/server/sonar-main/src/main/java/org/sonar/application/es/EsSettings.java +++ b/server/sonar-main/src/main/java/org/sonar/application/es/EsSettings.java @@ -38,6 +38,7 @@ import static java.lang.String.valueOf; import static org.sonar.process.ProcessProperties.Property.CLUSTER_ENABLED; import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_DISCOVERY_SEED_HOSTS; import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_HOSTS; +import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_HTTP_KEYSTORE; import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_KEYSTORE; import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_TRUSTSTORE; import static org.sonar.process.ProcessProperties.Property.CLUSTER_NAME; @@ -98,7 +99,7 @@ public class EsSettings { configureFileSystem(builder); configureNetwork(builder); configureCluster(builder); - configureAuthentication(builder); + configureSecurity(builder); configureOthers(builder); LOGGER.info("Elasticsearch listening on [HTTP: {}:{}, TCP: {}:{}]", builder.get(ES_HTTP_HOST_KEY), builder.get(ES_HTTP_PORT_KEY), @@ -111,7 +112,7 @@ public class EsSettings { builder.put("path.logs", fileSystem.getLogDirectory().getAbsolutePath()); } - private void configureAuthentication(Map builder) { + private void configureSecurity(Map builder) { if (clusterEnabled && props.value((CLUSTER_SEARCH_PASSWORD.getKey())) != null) { String clusterESKeystoreFileName = getFileNameFromPathProperty(CLUSTER_ES_KEYSTORE); @@ -123,6 +124,13 @@ public class EsSettings { builder.put("xpack.security.transport.ssl.verification_mode", "certificate"); builder.put("xpack.security.transport.ssl.keystore.path", clusterESKeystoreFileName); builder.put("xpack.security.transport.ssl.truststore.path", clusterESTruststoreFileName); + + if (props.value(CLUSTER_ES_HTTP_KEYSTORE.getKey()) != null) { + String clusterESHttpKeystoreFileName = getFileNameFromPathProperty(CLUSTER_ES_HTTP_KEYSTORE); + + builder.put("xpack.security.http.ssl.enabled", Boolean.TRUE.toString()); + builder.put("xpack.security.http.ssl.keystore.path", clusterESHttpKeystoreFileName); + } } else { builder.put("xpack.security.autoconfiguration.enabled", Boolean.FALSE.toString()); builder.put("xpack.security.enabled", Boolean.FALSE.toString()); diff --git a/server/sonar-main/src/test/java/org/sonar/application/ProcessLauncherImplTest.java b/server/sonar-main/src/test/java/org/sonar/application/ProcessLauncherImplTest.java index 2700d5ef0b2..bcea92db366 100644 --- a/server/sonar-main/src/test/java/org/sonar/application/ProcessLauncherImplTest.java +++ b/server/sonar-main/src/test/java/org/sonar/application/ProcessLauncherImplTest.java @@ -182,6 +182,39 @@ public class ProcessLauncherImplTest { assertThat(Paths.get(esInstallation.getConfDirectory().getAbsolutePath(), "keystore.pk12")).exists(); } + @Test + public void launch_whenEnablingEsWithHttpEncryption_shouldCopyKeystoreToEsConf() throws Exception { + File tempDir = temp.newFolder(); + File certificateFile = temp.newFile("certificate.pk12"); + File httpCertificateFile = temp.newFile("httpCertificate.pk12"); + TestProcessBuilder processBuilder = new TestProcessBuilder(); + ProcessLauncher underTest = new ProcessLauncherImpl(tempDir, commands, () -> processBuilder); + + EsInstallation esInstallation = createEsInstallation(new Props(new Properties()) + .set("sonar.cluster.enabled", "true") + .set("sonar.cluster.search.password", "bootstrap-password") + .set("sonar.cluster.es.ssl.keystore", certificateFile.getAbsolutePath()) + .set("sonar.cluster.es.ssl.truststore", certificateFile.getAbsolutePath()) + .set("sonar.cluster.es.http.ssl.keystore", httpCertificateFile.getAbsolutePath()) + .set("sonar.cluster.es.http.ssl.keystorePassword", "keystore-password")); + + JavaCommand command = new JavaCommand<>(ProcessId.ELASTICSEARCH, temp.newFolder()); + command.addClasspath("lib/*.class"); + command.addClasspath("lib/*.jar"); + command.setArgument("foo", "bar"); + command.setClassName("org.sonarqube.Main"); + command.setEnvVariable("VAR1", "valueOfVar1"); + command.setJvmOptions(new JvmOptions<>() + .add("-Dfoo=bar") + .add("-Dfoo2=bar2")); + command.setEsInstallation(esInstallation); + + ManagedProcess monitor = underTest.launch(command); + assertThat(monitor).isNotNull(); + assertThat(Paths.get(esInstallation.getConfDirectory().getAbsolutePath(), "certificate.pk12")).exists(); + assertThat(Paths.get(esInstallation.getConfDirectory().getAbsolutePath(), "httpCertificate.pk12")).exists(); + } + @Test public void properties_are_passed_to_command_via_a_temporary_properties_file() throws Exception { File tempDir = temp.newFolder(); diff --git a/server/sonar-main/src/test/java/org/sonar/application/es/EsConnectorImplTest.java b/server/sonar-main/src/test/java/org/sonar/application/es/EsConnectorImplTest.java index 34cc2abb673..2b6d9435259 100644 --- a/server/sonar-main/src/test/java/org/sonar/application/es/EsConnectorImplTest.java +++ b/server/sonar-main/src/test/java/org/sonar/application/es/EsConnectorImplTest.java @@ -21,13 +21,23 @@ package org.sonar.application.es; import com.google.common.collect.Sets; import com.google.common.net.HostAndPort; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyStore; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; +import okhttp3.tls.HandshakeCertificates; +import okhttp3.tls.HeldCertificate; import org.elasticsearch.ElasticsearchStatusException; import org.elasticsearch.cluster.health.ClusterHealthStatus; import org.junit.After; import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -76,8 +86,11 @@ public class EsConnectorImplTest { @Rule public MockWebServer mockWebServer = new MockWebServer(); + @Rule + public TemporaryFolder temp = new TemporaryFolder(); - EsConnectorImpl underTest = new EsConnectorImpl(Sets.newHashSet(HostAndPort.fromParts(mockWebServer.getHostName(), mockWebServer.getPort())), null); + EsConnectorImpl underTest = new EsConnectorImpl(Sets.newHashSet(HostAndPort.fromParts(mockWebServer.getHostName(), + mockWebServer.getPort())), null, null, null); @After public void after() { @@ -105,13 +118,53 @@ public class EsConnectorImplTest { mockServerResponse(200, JSON_SUCCESS_RESPONSE); String password = "test-password"; - EsConnectorImpl underTest = new EsConnectorImpl(Sets.newHashSet(HostAndPort.fromParts(mockWebServer.getHostName(), mockWebServer.getPort())), password); + EsConnectorImpl underTest = new EsConnectorImpl(Sets.newHashSet(HostAndPort.fromParts(mockWebServer.getHostName(), mockWebServer.getPort())), + password, null, null); assertThat(underTest.getClusterHealthStatus()) .hasValue(ClusterHealthStatus.YELLOW); assertThat(mockWebServer.takeRequest().getHeader("Authorization")).isEqualTo("Basic ZWxhc3RpYzp0ZXN0LXBhc3N3b3Jk"); } + @Test + public void newInstance_whenKeyStorePassed_shouldCreateClient() throws GeneralSecurityException, IOException { + mockServerResponse(200, JSON_SUCCESS_RESPONSE); + + Path keyStorePath = temp.newFile("keystore.p12").toPath(); + String password = "password"; + + HandshakeCertificates certificate = createCertificate(mockWebServer.getHostName(), keyStorePath, password); + mockWebServer.useHttps(certificate.sslSocketFactory(), false); + + EsConnectorImpl underTest = new EsConnectorImpl(Sets.newHashSet(HostAndPort.fromParts(mockWebServer.getHostName(), + mockWebServer.getPort())), null, keyStorePath, password); + + assertThat(underTest.getClusterHealthStatus()).hasValue(ClusterHealthStatus.YELLOW); + } + + private HandshakeCertificates createCertificate(String hostName, Path keyStorePath, String password) + throws GeneralSecurityException, IOException { + HeldCertificate localhostCertificate = new HeldCertificate.Builder() + .addSubjectAlternativeName(hostName) + .build(); + + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null); + ks.setKeyEntry("alias", localhostCertificate.keyPair().getPrivate(), password.toCharArray(), + new java.security.cert.Certificate[]{localhostCertificate.certificate()}); + ks.store(baos, password.toCharArray()); + + try (OutputStream outputStream = Files.newOutputStream(keyStorePath)) { + outputStream.write(baos.toByteArray()); + } + } + + return new HandshakeCertificates.Builder() + .heldCertificate(localhostCertificate) + .build(); + } + private void mockServerResponse(int httpCode, String jsonResponse) { mockWebServer.enqueue(new MockResponse() .setResponseCode(200) diff --git a/server/sonar-main/src/test/java/org/sonar/application/es/EsInstallationTest.java b/server/sonar-main/src/test/java/org/sonar/application/es/EsInstallationTest.java index 49734dc582a..64bf7646114 100644 --- a/server/sonar-main/src/test/java/org/sonar/application/es/EsInstallationTest.java +++ b/server/sonar-main/src/test/java/org/sonar/application/es/EsInstallationTest.java @@ -29,6 +29,9 @@ import org.sonar.process.Props; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.sonar.process.ProcessProperties.Property.CLUSTER_ENABLED; +import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_HTTP_KEYSTORE; +import static org.sonar.process.ProcessProperties.Property.CLUSTER_SEARCH_PASSWORD; import static org.sonar.process.ProcessProperties.Property.PATH_DATA; import static org.sonar.process.ProcessProperties.Property.PATH_HOME; import static org.sonar.process.ProcessProperties.Property.PATH_LOGS; @@ -201,4 +204,32 @@ public class EsInstallationTest { assertThat(underTest.getJvmOptions()).isEqualTo(new File(tempDir, "conf/es/jvm.options")); } + + @Test + public void isHttpEncryptionEnabled_shouldReturnCorrectValue() throws IOException { + File sqHomeDir = temp.newFolder(); + Props props = new Props(new Properties()); + props.set(PATH_DATA.getKey(), temp.newFolder().getAbsolutePath()); + props.set(PATH_HOME.getKey(), temp.newFolder().getAbsolutePath()); + props.set(PATH_TEMP.getKey(), sqHomeDir.getAbsolutePath()); + props.set(PATH_LOGS.getKey(), temp.newFolder().getAbsolutePath()); + props.set(CLUSTER_ENABLED.getKey(), "true"); + props.set(CLUSTER_SEARCH_PASSWORD.getKey(), "password"); + props.set(CLUSTER_ES_HTTP_KEYSTORE.getKey(), sqHomeDir.getAbsolutePath()); + + EsInstallation underTest = new EsInstallation(props); + assertThat(underTest.isHttpEncryptionEnabled()).isTrue(); + + props.set(CLUSTER_ENABLED.getKey(), "false"); + props.set(CLUSTER_ES_HTTP_KEYSTORE.getKey(), sqHomeDir.getAbsolutePath()); + + underTest = new EsInstallation(props); + assertThat(underTest.isHttpEncryptionEnabled()).isFalse(); + + props.set(CLUSTER_ENABLED.getKey(), "true"); + props.rawProperties().remove(CLUSTER_ES_HTTP_KEYSTORE.getKey()); + + underTest = new EsInstallation(props); + assertThat(underTest.isHttpEncryptionEnabled()).isFalse(); + } } diff --git a/server/sonar-main/src/test/java/org/sonar/application/es/EsSettingsTest.java b/server/sonar-main/src/test/java/org/sonar/application/es/EsSettingsTest.java index fb66bce27df..8c2518d12a9 100644 --- a/server/sonar-main/src/test/java/org/sonar/application/es/EsSettingsTest.java +++ b/server/sonar-main/src/test/java/org/sonar/application/es/EsSettingsTest.java @@ -50,6 +50,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_HOSTS; +import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_HTTP_KEYSTORE; import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_KEYSTORE; import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_TRUSTSTORE; import static org.sonar.process.ProcessProperties.Property.CLUSTER_NAME; @@ -367,7 +368,7 @@ public class EsSettingsTest { } @Test - public void configureAuthentication_givenClusterSearchPasswordNotProvided_dontAddXpackParameters() throws Exception { + public void configureSecurity_givenClusterSearchPasswordNotProvided_dontAddXpackParameters() throws Exception { Props props = minProps(true); EsSettings settings = new EsSettings(props, new EsInstallation(props), system); @@ -378,7 +379,7 @@ public class EsSettingsTest { } @Test - public void configureAuthentication_givenClusterSearchPasswordProvided_addXpackParameters_file_exists() throws Exception { + public void configureSecurity_givenClusterSearchPasswordProvided_addXpackParameters_file_exists() throws Exception { Props props = minProps(true); props.set(CLUSTER_SEARCH_PASSWORD.getKey(), "qwerty"); File keystore = temp.newFile("keystore.p12"); @@ -399,7 +400,7 @@ public class EsSettingsTest { } @Test - public void configureAuthentication_givenClusterSearchPasswordProvidedButKeystorePathMissing_throwException() throws Exception { + public void configureSecurity_givenClusterSearchPasswordProvidedButKeystorePathMissing_throwException() throws Exception { Props props = minProps(true); props.set(CLUSTER_SEARCH_PASSWORD.getKey(), "qwerty"); @@ -411,7 +412,7 @@ public class EsSettingsTest { } @Test - public void configureAuthentication_givenClusterModeFalse_dontAddXpackParameters() throws Exception { + public void configureSecurity_givenClusterModeFalse_dontAddXpackParameters() throws Exception { Props props = minProps(false); props.set(CLUSTER_SEARCH_PASSWORD.getKey(), "qwerty"); @@ -423,7 +424,7 @@ public class EsSettingsTest { } @Test - public void configureAuthentication_givenFileNotExist_throwException() throws Exception { + public void configureSecurity_givenFileNotExist_throwException() throws Exception { Props props = minProps(true); props.set(CLUSTER_SEARCH_PASSWORD.getKey(), "qwerty"); File truststore = temp.newFile("truststore.p12"); @@ -438,6 +439,61 @@ public class EsSettingsTest { .hasMessage("Unable to configure: sonar.cluster.es.ssl.keystore. File specified in [not-existing-file] does not exist"); } + @Test + public void configureSecurity_whenHttpKeystoreProvided_shouldAddHttpProperties() throws Exception { + Props props = minProps(true); + File keystore = temp.newFile("keystore.p12"); + File truststore = temp.newFile("truststore.p12"); + File httpKeystore = temp.newFile("http-keystore.p12"); + props.set(CLUSTER_SEARCH_PASSWORD.getKey(), "qwerty"); + props.set(CLUSTER_ES_KEYSTORE.getKey(), keystore.getAbsolutePath()); + props.set(CLUSTER_ES_TRUSTSTORE.getKey(), truststore.getAbsolutePath()); + props.set(CLUSTER_ES_HTTP_KEYSTORE.getKey(), httpKeystore.getAbsolutePath()); + + EsSettings settings = new EsSettings(props, new EsInstallation(props), system); + + Map outputParams = settings.build(); + + assertThat(outputParams) + .containsEntry("xpack.security.http.ssl.enabled", "true") + .containsEntry("xpack.security.http.ssl.keystore.path", httpKeystore.getName()); + } + + @Test + public void configureSecurity_whenHttpKeystoreNotProvided_shouldNotAddHttpProperties() throws Exception { + Props props = minProps(true); + File keystore = temp.newFile("keystore.p12"); + File truststore = temp.newFile("truststore.p12"); + props.set(CLUSTER_SEARCH_PASSWORD.getKey(), "qwerty"); + props.set(CLUSTER_ES_KEYSTORE.getKey(), keystore.getAbsolutePath()); + props.set(CLUSTER_ES_TRUSTSTORE.getKey(), truststore.getAbsolutePath()); + + EsSettings settings = new EsSettings(props, new EsInstallation(props), system); + + Map outputParams = settings.build(); + + assertThat(outputParams) + .doesNotContainKey("xpack.security.http.ssl.enabled") + .doesNotContainKey("xpack.security.http.ssl.keystore.path"); + } + + @Test + public void configureSecurity_whenHttpKeystoreProvided_shouldFailIfNotExists() throws Exception { + Props props = minProps(true); + File keystore = temp.newFile("keystore.p12"); + File truststore = temp.newFile("truststore.p12"); + props.set(CLUSTER_SEARCH_PASSWORD.getKey(), "qwerty"); + props.set(CLUSTER_ES_KEYSTORE.getKey(), keystore.getAbsolutePath()); + props.set(CLUSTER_ES_TRUSTSTORE.getKey(), truststore.getAbsolutePath()); + props.set(CLUSTER_ES_HTTP_KEYSTORE.getKey(), "not-existing-file"); + + EsSettings settings = new EsSettings(props, new EsInstallation(props), system); + + assertThatThrownBy(settings::build) + .isInstanceOf(MessageException.class) + .hasMessage("Unable to configure: sonar.cluster.es.http.ssl.keystore. File specified in [not-existing-file] does not exist"); + } + @DataProvider public static Object[][] clusterEnabledOrNot() { return new Object[][] { diff --git a/server/sonar-process/src/main/java/org/sonar/process/ProcessProperties.java b/server/sonar-process/src/main/java/org/sonar/process/ProcessProperties.java index 822de64d3a7..ab1c4f5e7e7 100644 --- a/server/sonar-process/src/main/java/org/sonar/process/ProcessProperties.java +++ b/server/sonar-process/src/main/java/org/sonar/process/ProcessProperties.java @@ -135,6 +135,8 @@ public class ProcessProperties { CLUSTER_ES_TRUSTSTORE("sonar.cluster.es.ssl.truststore"), CLUSTER_ES_KEYSTORE_PASSWORD("sonar.cluster.es.ssl.keystorePassword"), CLUSTER_ES_TRUSTSTORE_PASSWORD("sonar.cluster.es.ssl.truststorePassword"), + CLUSTER_ES_HTTP_KEYSTORE("sonar.cluster.es.http.ssl.keystore"), + CLUSTER_ES_HTTP_KEYSTORE_PASSWORD("sonar.cluster.es.http.ssl.keystorePassword"), // search node only settings CLUSTER_ES_HOSTS("sonar.cluster.es.hosts"), CLUSTER_ES_DISCOVERY_SEED_HOSTS("sonar.cluster.es.discovery.seed.hosts"), diff --git a/server/sonar-server-common/build.gradle b/server/sonar-server-common/build.gradle index 67dec37f8cc..0ba7fb41ca6 100644 --- a/server/sonar-server-common/build.gradle +++ b/server/sonar-server-common/build.gradle @@ -31,6 +31,7 @@ dependencies { testImplementation 'com.google.code.findbugs:jsr305' testImplementation 'org.subethamail:subethasmtp' testImplementation 'com.squareup.okhttp3:mockwebserver' + testImplementation 'com.squareup.okhttp3:okhttp-tls' testImplementation 'com.squareup.okio:okio' testImplementation 'com.tngtech.java:junit-dataprovider' testImplementation 'junit:junit' diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/es/EsClient.java b/server/sonar-server-common/src/main/java/org/sonar/server/es/EsClient.java index ca881091fad..27bed9e5a23 100644 --- a/server/sonar-server-common/src/main/java/org/sonar/server/es/EsClient.java +++ b/server/sonar-server-common/src/main/java/org/sonar/server/es/EsClient.java @@ -25,13 +25,21 @@ import com.google.gson.GsonBuilder; import com.google.gson.JsonObject; import java.io.Closeable; import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.GeneralSecurityException; +import java.security.KeyStore; import java.util.Arrays; import java.util.function.Supplier; import javax.annotation.Nullable; +import javax.net.ssl.SSLContext; import org.apache.http.HttpHost; import org.apache.http.auth.AuthScope; import org.apache.http.auth.UsernamePasswordCredentials; import org.apache.http.impl.client.BasicCredentialsProvider; +import org.apache.http.ssl.SSLContextBuilder; +import org.apache.http.ssl.SSLContexts; import org.apache.http.util.EntityUtils; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.admin.cluster.health.ClusterHealthRequest; @@ -41,9 +49,6 @@ import org.elasticsearch.action.admin.indices.cache.clear.ClearIndicesCacheRespo import org.elasticsearch.action.admin.indices.delete.DeleteIndexRequest; import org.elasticsearch.action.admin.indices.forcemerge.ForceMergeRequest; import org.elasticsearch.action.admin.indices.forcemerge.ForceMergeResponse; -import org.elasticsearch.client.indices.GetMappingsRequest; -import org.elasticsearch.client.indices.GetMappingsResponse; -import org.elasticsearch.client.indices.PutMappingRequest; import org.elasticsearch.action.admin.indices.refresh.RefreshRequest; import org.elasticsearch.action.admin.indices.refresh.RefreshResponse; import org.elasticsearch.action.admin.indices.settings.get.GetSettingsRequest; @@ -75,6 +80,9 @@ import org.elasticsearch.client.indices.CreateIndexRequest; import org.elasticsearch.client.indices.CreateIndexResponse; import org.elasticsearch.client.indices.GetIndexRequest; import org.elasticsearch.client.indices.GetIndexResponse; +import org.elasticsearch.client.indices.GetMappingsRequest; +import org.elasticsearch.client.indices.GetMappingsResponse; +import org.elasticsearch.client.indices.PutMappingRequest; import org.elasticsearch.cluster.health.ClusterHealthStatus; import org.elasticsearch.common.Priority; import org.jetbrains.annotations.NotNull; @@ -98,11 +106,11 @@ public class EsClient implements Closeable { private final Gson gson; public EsClient(HttpHost... hosts) { - this(new MinimalRestHighLevelClient(null, hosts)); + this(new MinimalRestHighLevelClient(null, null, null, hosts)); } - public EsClient(@Nullable String searchPassword, HttpHost... hosts) { - this(new MinimalRestHighLevelClient(searchPassword, hosts)); + public EsClient(@Nullable String searchPassword, @Nullable String keyStorePath, @Nullable String keyStorePassword, HttpHost... hosts) { + this(new MinimalRestHighLevelClient(searchPassword, keyStorePath, keyStorePassword, hosts)); } EsClient(RestHighLevelClient restHighLevelClient) { @@ -265,8 +273,9 @@ public class EsClient implements Closeable { private static final int CONNECT_TIMEOUT = 5000; private static final int SOCKET_TIMEOUT = 60000; - public MinimalRestHighLevelClient(@Nullable String searchPassword, HttpHost... hosts) { - super(buildHttpClient(searchPassword, hosts).build(), RestClient::close, Lists.newArrayList(), true); + public MinimalRestHighLevelClient(@Nullable String searchPassword, @Nullable String keyStorePath, + @Nullable String keyStorePassword, HttpHost... hosts) { + super(buildHttpClient(searchPassword, keyStorePath, keyStorePassword, hosts).build(), RestClient::close, Lists.newArrayList(), true); } MinimalRestHighLevelClient(RestClient restClient) { @@ -274,8 +283,8 @@ public class EsClient implements Closeable { } @NotNull - private static RestClientBuilder buildHttpClient(@Nullable String searchPassword, - HttpHost[] hosts) { + private static RestClientBuilder buildHttpClient(@Nullable String searchPassword, @Nullable String keyStorePath, + @Nullable String keyStorePassword, HttpHost[] hosts) { return RestClient.builder(hosts) .setRequestConfigCallback(r -> r .setConnectTimeout(CONNECT_TIMEOUT) @@ -285,6 +294,12 @@ public class EsClient implements Closeable { BasicCredentialsProvider provider = getBasicCredentialsProvider(searchPassword); httpClientBuilder.setDefaultCredentialsProvider(provider); } + + if (keyStorePath != null) { + SSLContext sslContext = getSSLContext(keyStorePath, keyStorePassword); + httpClientBuilder.setSSLContext(sslContext); + } + return httpClientBuilder; }); } @@ -295,6 +310,18 @@ public class EsClient implements Closeable { return provider; } + private static SSLContext getSSLContext(String keyStorePath, @Nullable String keyStorePassword) { + try { + KeyStore keyStore = KeyStore.getInstance("pkcs12"); + try (InputStream is = Files.newInputStream(Paths.get(keyStorePath))) { + keyStore.load(is, keyStorePassword == null ? null : keyStorePassword.toCharArray()); + } + SSLContextBuilder sslBuilder = SSLContexts.custom().loadTrustMaterial(keyStore, null); + return sslBuilder.build(); + } catch (IOException | GeneralSecurityException e) { + throw new IllegalStateException("Failed to setup SSL context on ES client", e); + } + } } R execute(EsRequestExecutor executor) { diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/es/EsClientProvider.java b/server/sonar-server-common/src/main/java/org/sonar/server/es/EsClientProvider.java index fdb52994314..69d9d0e842c 100644 --- a/server/sonar-server-common/src/main/java/org/sonar/server/es/EsClientProvider.java +++ b/server/sonar-server-common/src/main/java/org/sonar/server/es/EsClientProvider.java @@ -37,6 +37,8 @@ import org.sonar.process.cluster.NodeType; import org.springframework.context.annotation.Bean; import static org.sonar.process.ProcessProperties.Property.CLUSTER_ENABLED; +import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_HTTP_KEYSTORE; +import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_HTTP_KEYSTORE_PASSWORD; import static org.sonar.process.ProcessProperties.Property.CLUSTER_NAME; import static org.sonar.process.ProcessProperties.Property.CLUSTER_NODE_TYPE; import static org.sonar.process.ProcessProperties.Property.CLUSTER_SEARCH_HOSTS; @@ -69,23 +71,27 @@ public class EsClientProvider { // * in org.sonar.process.ProcessProperties.Property.SEARCH_HOST // * in org.sonar.process.ProcessProperties.Property.SEARCH_PORT HostAndPort host = HostAndPort.fromParts(config.get(SEARCH_HOST.getKey()).get(), config.getInt(SEARCH_PORT.getKey()).get()); - httpHosts = Collections.singletonList(toHttpHost(host)); + httpHosts = Collections.singletonList(toHttpHost(host, config)); LOGGER.info("Connected to local Elasticsearch: [{}]", displayedAddresses(httpHosts)); } - return new EsClient(config.get(CLUSTER_SEARCH_PASSWORD.getKey()).orElse(null), httpHosts.toArray(new HttpHost[0])); + return new EsClient(config.get(CLUSTER_SEARCH_PASSWORD.getKey()).orElse(null), + config.get(CLUSTER_ES_HTTP_KEYSTORE.getKey()).orElse(null), + config.get(CLUSTER_ES_HTTP_KEYSTORE_PASSWORD.getKey()).orElse(null), + httpHosts.toArray(new HttpHost[0])); } private static List getHttpHosts(Configuration config) { return Arrays.stream(config.getStringArray(CLUSTER_SEARCH_HOSTS.getKey())) .map(HostAndPort::fromString) - .map(EsClientProvider::toHttpHost) + .map(host -> toHttpHost(host, config)) .toList(); } - private static HttpHost toHttpHost(HostAndPort host) { + private static HttpHost toHttpHost(HostAndPort host, Configuration config) { try { - return new HttpHost(InetAddress.getByName(host.getHost()), host.getPortOrDefault(9001)); + String scheme = config.get(CLUSTER_ES_HTTP_KEYSTORE.getKey()).isPresent() ? "https" : HttpHost.DEFAULT_SCHEME_NAME; + return new HttpHost(InetAddress.getByName(host.getHost()), host.getPortOrDefault(9001), scheme); } catch (UnknownHostException e) { throw new IllegalStateException("Can not resolve host [" + host + "]", e); } diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/es/EsClientProviderTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/es/EsClientProviderTest.java index 329a3062ea1..c7b8ecfebc0 100644 --- a/server/sonar-server-common/src/test/java/org/sonar/server/es/EsClientProviderTest.java +++ b/server/sonar-server-common/src/test/java/org/sonar/server/es/EsClientProviderTest.java @@ -19,13 +19,17 @@ */ package org.sonar.server.es; +import java.io.IOException; import java.net.InetAddress; +import java.nio.file.Path; +import java.security.GeneralSecurityException; import org.assertj.core.api.Condition; import org.elasticsearch.client.Node; import org.elasticsearch.client.RestHighLevelClient; import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import org.slf4j.event.Level; import org.sonar.api.config.internal.MapSettings; import org.sonar.api.testfixtures.log.LogTester; @@ -34,6 +38,7 @@ import static java.lang.String.format; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.sonar.process.ProcessProperties.Property.CLUSTER_ENABLED; +import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_HTTP_KEYSTORE; import static org.sonar.process.ProcessProperties.Property.CLUSTER_NAME; import static org.sonar.process.ProcessProperties.Property.CLUSTER_NODE_TYPE; import static org.sonar.process.ProcessProperties.Property.CLUSTER_SEARCH_HOSTS; @@ -44,6 +49,8 @@ import static org.sonar.process.ProcessProperties.Property.SEARCH_PORT; public class EsClientProviderTest { @Rule public LogTester logTester = new LogTester(); + @Rule + public TemporaryFolder temp = new TemporaryFolder(); private MapSettings settings = new MapSettings(); private EsClientProvider underTest = new EsClientProvider(); @@ -140,4 +147,23 @@ public class EsClientProviderTest { assertThat(logTester.logs(Level.INFO)) .has(new Condition<>(s -> s.contains("Connected to remote Elasticsearch: [http://" + localhostHostname + ":9001, http://" + localhostHostname + ":8081]"), "")); } + + @Test + public void provide_whenHttpEncryptionEnabled_shouldUseHttps() throws GeneralSecurityException, IOException { + settings.setProperty(CLUSTER_ENABLED.getKey(), true); + Path keyStorePath = temp.newFile("keystore.p12").toPath(); + EsClientTest.createCertificate("localhost", keyStorePath, "password"); + settings.setProperty(CLUSTER_ES_HTTP_KEYSTORE.getKey(), keyStorePath.toString()); + settings.setProperty(CLUSTER_NODE_TYPE.getKey(), "application"); + settings.setProperty(CLUSTER_SEARCH_HOSTS.getKey(), format("%s,%s:8081", localhostHostname, localhostHostname)); + + EsClient client = underTest.provide(settings.asConfig()); + RestHighLevelClient nativeClient = client.nativeClient(); + + Node node = nativeClient.getLowLevelClient().getNodes().get(0); + assertThat(node.getHost().getSchemeName()).isEqualTo("https"); + + assertThat(logTester.logs(Level.INFO)) + .has(new Condition<>(s -> s.contains("Connected to remote Elasticsearch: [https://" + localhostHostname + ":9001, https://" + localhostHostname + ":8081]"), "")); + } } diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/es/EsClientTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/es/EsClientTest.java index 97d2d899c48..46dc39ebc83 100644 --- a/server/sonar-server-common/src/test/java/org/sonar/server/es/EsClientTest.java +++ b/server/sonar-server-common/src/test/java/org/sonar/server/es/EsClientTest.java @@ -20,10 +20,18 @@ package org.sonar.server.es; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyStore; import java.util.Objects; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; +import okhttp3.tls.HandshakeCertificates; +import okhttp3.tls.HeldCertificate; import org.apache.http.HttpEntity; import org.apache.http.HttpHost; import org.elasticsearch.client.Request; @@ -32,6 +40,7 @@ import org.elasticsearch.client.RestClient; import org.elasticsearch.client.RestHighLevelClient; import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import org.mockito.ArgumentMatcher; import static org.assertj.core.api.Assertions.assertThat; @@ -151,6 +160,8 @@ public class EsClientTest { @Rule public MockWebServer mockWebServer = new MockWebServer(); + @Rule + public TemporaryFolder temp = new TemporaryFolder(); RestClient restClient = mock(RestClient.class); RestHighLevelClient client = new EsClient.MinimalRestHighLevelClient(restClient); @@ -179,7 +190,7 @@ public class EsClientTest { when(restClient.performRequest(argThat(new RawRequestMatcher( "GET", "/_nodes/stats/fs,process,jvm,indices,breaker")))) - .thenReturn(response); + .thenReturn(response); assertThat(underTest.nodesStats()).isNotNull(); } @@ -189,7 +200,7 @@ public class EsClientTest { when(restClient.performRequest(argThat(new RawRequestMatcher( "GET", "/_nodes/stats/fs,process,jvm,indices,breaker")))) - .thenThrow(IOException.class); + .thenThrow(IOException.class); assertThatThrownBy(() -> underTest.nodesStats()) .isInstanceOf(ElasticsearchException.class); @@ -204,7 +215,7 @@ public class EsClientTest { when(restClient.performRequest(argThat(new RawRequestMatcher( "GET", "/_stats")))) - .thenReturn(response); + .thenReturn(response); assertThat(underTest.indicesStats()).isNotNull(); } @@ -214,7 +225,7 @@ public class EsClientTest { when(restClient.performRequest(argThat(new RawRequestMatcher( "GET", "/_stats")))) - .thenThrow(IOException.class); + .thenThrow(IOException.class); assertThatThrownBy(() -> underTest.indicesStats()) .isInstanceOf(ElasticsearchException.class); @@ -230,7 +241,7 @@ public class EsClientTest { when(restClient.performRequest(argThat(new RawRequestMatcher( "GET", "/_cluster/stats")))) - .thenReturn(response); + .thenReturn(response); assertThat(underTest.clusterStats()).isNotNull(); } @@ -240,7 +251,7 @@ public class EsClientTest { when(restClient.performRequest(argThat(new RawRequestMatcher( "GET", "/_cluster/stats")))) - .thenThrow(IOException.class); + .thenThrow(IOException.class); assertThatThrownBy(() -> underTest.clusterStats()) .isInstanceOf(ElasticsearchException.class); @@ -254,13 +265,54 @@ public class EsClientTest { .setHeader("Content-Type", "application/json")); String password = "test-password"; - EsClient underTest = new EsClient(password, new HttpHost(mockWebServer.getHostName(), mockWebServer.getPort())); + EsClient underTest = new EsClient(password, null, null, new HttpHost(mockWebServer.getHostName(), mockWebServer.getPort())); - underTest.clusterStats(); + assertThat(underTest.clusterStats()).isNotNull(); assertThat(mockWebServer.takeRequest().getHeader("Authorization")).isEqualTo("Basic ZWxhc3RpYzp0ZXN0LXBhc3N3b3Jk"); } + @Test + public void newInstance_whenKeyStorePassed_shouldCreateClient() throws GeneralSecurityException, IOException { + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(EXAMPLE_CLUSTER_STATS_JSON) + .setHeader("Content-Type", "application/json")); + + Path keyStorePath = temp.newFile("keystore.p12").toPath(); + String password = "password"; + + HandshakeCertificates certificate = createCertificate(mockWebServer.getHostName(), keyStorePath, password); + mockWebServer.useHttps(certificate.sslSocketFactory(), false); + + EsClient underTest = new EsClient(null, keyStorePath.toString(), password, + new HttpHost(mockWebServer.getHostName(), mockWebServer.getPort(), "https")); + + assertThat(underTest.clusterStats()).isNotNull(); + } + + static HandshakeCertificates createCertificate(String hostName, Path keyStorePath, String password) throws GeneralSecurityException, IOException { + HeldCertificate localhostCertificate = new HeldCertificate.Builder() + .addSubjectAlternativeName(hostName) + .build(); + + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null); + ks.setKeyEntry("alias", localhostCertificate.keyPair().getPrivate(), password.toCharArray(), + new java.security.cert.Certificate[]{localhostCertificate.certificate()}); + ks.store(baos, password.toCharArray()); + + try (OutputStream outputStream = Files.newOutputStream(keyStorePath)) { + outputStream.write(baos.toByteArray()); + } + } + + return new HandshakeCertificates.Builder() + .heldCertificate(localhostCertificate) + .build(); + } + static class RawRequestMatcher implements ArgumentMatcher { String endpoint; String method; -- 2.39.5