]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-14853 Elasticsearch http encryption
authorEric Giffon <eric.giffon@sonarsource.com>
Wed, 14 Jun 2023 15:03:47 +0000 (17:03 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 20 Jun 2023 15:13:44 +0000 (15:13 +0000)
18 files changed:
build.gradle
server/sonar-main/build.gradle
server/sonar-main/src/main/java/org/sonar/application/AppStateFactory.java
server/sonar-main/src/main/java/org/sonar/application/ProcessLauncherImpl.java
server/sonar-main/src/main/java/org/sonar/application/es/EsConnectorImpl.java
server/sonar-main/src/main/java/org/sonar/application/es/EsInstallation.java
server/sonar-main/src/main/java/org/sonar/application/es/EsKeyStoreCli.java
server/sonar-main/src/main/java/org/sonar/application/es/EsSettings.java
server/sonar-main/src/test/java/org/sonar/application/ProcessLauncherImplTest.java
server/sonar-main/src/test/java/org/sonar/application/es/EsConnectorImplTest.java
server/sonar-main/src/test/java/org/sonar/application/es/EsInstallationTest.java
server/sonar-main/src/test/java/org/sonar/application/es/EsSettingsTest.java
server/sonar-process/src/main/java/org/sonar/process/ProcessProperties.java
server/sonar-server-common/build.gradle
server/sonar-server-common/src/main/java/org/sonar/server/es/EsClient.java
server/sonar-server-common/src/main/java/org/sonar/server/es/EsClientProvider.java
server/sonar-server-common/src/test/java/org/sonar/server/es/EsClientProviderTest.java
server/sonar-server-common/src/test/java/org/sonar/server/es/EsClientTest.java

index be4171dd36d0071ae5d799352825190235ff79cb..aa72c7d4313518f1b7f3e55700eab0c868c1264b 100644 (file)
@@ -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'
index 0e554b7104db4127abc2f191322b51f70e9840ef..cb419dbac6260b20efe52bc13e6330c5371abb2e 100644 (file)
@@ -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')
 }
index 1d81bcab68ecff0ed781dd7268c65d0cf96fc91f..b61aefbe4deae934df70c3d9f2aa5ff28f20c38b 100644 (file)
 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);
   }
 }
index f8c2952070e104918b25eb9a2f84cc75bf7d155c..06c95efaff941634eaacd5d0a86d1034decc340a 100644 (file)
@@ -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));
     }
   }
 
index 9906aeba541758e88bcdbcf6be17d97928c2f771..d4fe260aa3afcd2df1679a121fd57ddf62bedfc0 100644 (file)
@@ -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<RestHighLevelClient> restClient = new AtomicReference<>(null);
   private final Set<HostAndPort> hostAndPorts;
   private final String searchPassword;
+  private final Path keyStorePath;
+  private final String keyStorePassword;
 
-  public EsConnectorImpl(Set<HostAndPort> hostAndPorts, @Nullable String searchPassword) {
+  public EsConnectorImpl(Set<HostAndPort> 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);
+    }
+  }
 }
index ebda731f75377f8e9c89514fadb4ac31a09563be..68b21afdc220fe171a97dc72674583be962ca7d3 100644 (file)
@@ -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<String> getTrustStorePassword() {
     return Optional.ofNullable(trustStorePassword);
   }
+
+  public Path getHttpKeyStoreLocation() {
+    return httpKeyStoreLocation;
+  }
+
+  public Optional<String> getHttpKeyStorePassword() {
+    return Optional.ofNullable(httpKeyStorePassword);
+  }
+
+  public boolean isHttpEncryptionEnabled() {
+    return httpEncryptionEnabled;
+  }
 }
index 745141205676727bd72a61aabc008f1654bd0659..d9daddef78f59c3d0fe67e9e24c1485144248346 100644 (file)
@@ -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";
 
index 843fd1e358561e9726acdddcad1d90f149b90226..b438fd886849c188a6a610f84a193d32678c0f78 100644 (file)
@@ -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<String, String> builder) {
+  private void configureSecurity(Map<String, String> 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());
index 2700d5ef0b231b3e2860e2f3970a6843c6945372..bcea92db3663a6edd35312073dba4b42a3d30d71 100644 (file)
@@ -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<JvmOptions> 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();
index 34cc2abb673607a7b8a1bc178aeab161f2529f19..2b6d94352597a42d8c28853caa336aa2fa3bbf9a 100644 (file)
@@ -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)
index 49734dc582a3980de5f3fc352dd735e0312110c9..64bf76461143fc87103c858e154dce064a10f96d 100644 (file)
@@ -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();
+  }
 }
index fb66bce27dfcd32530c3ab853a7d6c6cf30c508c..8c2518d12a9f01eb3105b60634d89e05b4684242 100644 (file)
@@ -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<String, String> 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<String, String> 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[][] {
index 822de64d3a75c99b014a05c213dd2b7aafe5cc53..ab1c4f5e7e71312b2c3ea8b50a338a6dfd6b85ee 100644 (file)
@@ -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"),
index 67dec37f8ccd4b8df8b37cad1bfe5eb4f3550d14..0ba7fb41ca679730fe2415c5f802d926dbab7af4 100644 (file)
@@ -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'
index ca881091fad2c7754c16ec4000fd7b09c9b86219..27bed9e5a2312fe55c522a4b300b58a10f8ff893 100644 (file)
@@ -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> R execute(EsRequestExecutor<R> executor) {
index fdb52994314c7080bcf4b40c2013df6d0888aeb9..69d9d0e842c11a912dece7f54f5a1021506ecb2a 100644 (file)
@@ -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<HttpHost> 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);
     }
index 329a3062ea1ff6da5dfffa05fa08c825f7d77759..c7b8ecfebc05cc4fc9db06f786957cd61ff803c1 100644 (file)
  */
 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]"), ""));
+  }
 }
index 97d2d899c48a545f733de93033a974856640b4c9..46dc39ebc831b74a2c5bc682e968cb32aeb59378 100644 (file)
 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<Request> {
     String endpoint;
     String method;