@@ -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' |
@@ -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') | |||
} |
@@ -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); | |||
} | |||
} |
@@ -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)); | |||
} | |||
} | |||
@@ -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); | |||
} | |||
} | |||
} |
@@ -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; | |||
} | |||
} |
@@ -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"; | |||
@@ -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()); |
@@ -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(); |
@@ -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) |
@@ -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(); | |||
} | |||
} |
@@ -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[][] { |
@@ -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"), |
@@ -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' |
@@ -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) { |
@@ -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); | |||
} |
@@ -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]"), "")); | |||
} | |||
} |
@@ -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<Request> { | |||
String endpoint; | |||
String method; |