Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Elasticsearch: Ensure Elasticsearch 8 works OOTB secure as default #5099

Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,21 +1,38 @@
package org.testcontainers.elasticsearch;

import static java.net.HttpURLConnection.HTTP_OK;
import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;

import java.net.InetSocketAddress;
import java.time.Duration;
import com.github.dockerjava.api.command.InspectContainerResponse;
import org.apache.commons.io.IOUtils;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.HttpWaitStrategy;
import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy;
import org.testcontainers.utility.Base58;
import org.testcontainers.utility.DockerImageName;

import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import java.io.ByteArrayInputStream;
import java.net.InetSocketAddress;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
import java.time.Duration;
import java.util.Optional;
import java.util.regex.Pattern;

import static java.net.HttpURLConnection.HTTP_OK;
import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;

/**
* Represents an elasticsearch docker instance which exposes by default port 9200 and 9300 (transport.tcp.port)
* The docker image is by default fetched from docker.elastic.co/elasticsearch/elasticsearch
*/
public class ElasticsearchContainer extends GenericContainer<ElasticsearchContainer> {

/**
* Elasticsearch Default Password for Elasticsearch &gt;= 8
*/
public static final String ELASTICSEARCH_DEFAULT_PASSWORD = "changeme";

/**
* Elasticsearch Default HTTP port
*/
Expand All @@ -39,7 +56,13 @@ public class ElasticsearchContainer extends GenericContainer<ElasticsearchContai
*/
@Deprecated
protected static final String DEFAULT_TAG = "7.9.2";
private boolean isOss = false;

// matches 8.x.x, 9.x.x and any double digit version, also supports those -alpha2 suffixes
private static final Pattern VERSION_PATTERN = Pattern.compile("^([89]|\\d{2})\\.\\d+\\.\\d+.*");

private final boolean isOss;
private final boolean isAtLeastMajorVersion8;
private Optional<byte[]> caCertAsBytes = Optional.empty();

/**
* @deprecated use {@link ElasticsearchContainer(DockerImageName)} instead
Expand All @@ -65,23 +88,71 @@ public ElasticsearchContainer(final DockerImageName dockerImageName) {
super(dockerImageName);

dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, DEFAULT_OSS_IMAGE_NAME);

if (dockerImageName.isCompatibleWith(DEFAULT_OSS_IMAGE_NAME)) {
this.isOss = true;
}
this.isOss = dockerImageName.isCompatibleWith(DEFAULT_OSS_IMAGE_NAME);

logger().info("Starting an elasticsearch container using [{}]", dockerImageName);
withNetworkAliases("elasticsearch-" + Base58.randomString(6));
withEnv("discovery.type", "single-node");
addExposedPorts(ELASTICSEARCH_DEFAULT_PORT, ELASTICSEARCH_DEFAULT_TCP_PORT);
setWaitStrategy(new HttpWaitStrategy()
.forPort(ELASTICSEARCH_DEFAULT_PORT)
.forStatusCodeMatching(response -> response == HTTP_OK || response == HTTP_UNAUTHORIZED)
.withStartupTimeout(Duration.ofMinutes(2)));
this.isAtLeastMajorVersion8 = VERSION_PATTERN.matcher(dockerImageName.getVersionPart()).matches();
if (isAtLeastMajorVersion8) {
spinscale marked this conversation as resolved.
Show resolved Hide resolved
// TLS using a self signed certificate is enabled by default in version 8
// to prevent the HttpsUrlConnection to fail with certificate errors we use
// the log message wait strategy, as there will be a single JSON encoded message
// marking the node as started
setWaitStrategy(new LogMessageWaitStrategy().withRegEx(".*\"message\":\"started\".*"));
withPassword(ELASTICSEARCH_DEFAULT_PASSWORD);
} else {
setWaitStrategy(new HttpWaitStrategy()
.forPort(ELASTICSEARCH_DEFAULT_PORT)
.forStatusCodeMatching(response -> response == HTTP_OK || response == HTTP_UNAUTHORIZED)
.withStartupTimeout(Duration.ofMinutes(2)));
}
kiview marked this conversation as resolved.
Show resolved Hide resolved
}

@Override
protected void containerIsStarted(InspectContainerResponse containerInfo) {
if (isAtLeastMajorVersion8) {
byte[] bytes = copyFileFromContainer("/usr/share/elasticsearch/config/certs/http_ca.crt", IOUtils::toByteArray);
if (bytes.length > 0) {
this.caCertAsBytes = Optional.of(bytes);
}
}
}

/**
* If this is running above Elasticsearch 8, the probably self signed CA cert will be extracted
spinscale marked this conversation as resolved.
Show resolved Hide resolved
*
* @return byte array optional containing the CA cert extracted from the docker container
*/
public Optional<byte[]> caCertAsBytes() {
kiview marked this conversation as resolved.
Show resolved Hide resolved
return caCertAsBytes;
}

/**
* A SSL context based on the self signed CA, so that using this SSL Context allows to connect to the Elasticsearch service
* @return a customized SSL Context
*/
public SSLContext createSslContextFromCa() {
try {
CertificateFactory factory = CertificateFactory.getInstance("X.509");
Certificate trustedCa = factory.generateCertificate(new ByteArrayInputStream(caCertAsBytes.get()));
KeyStore trustStore = KeyStore.getInstance("pkcs12");
trustStore.load(null, null);
trustStore.setCertificateEntry("ca", trustedCa);

final SSLContext sslContext = SSLContext.getInstance("TLSv1.3");
TrustManagerFactory tmfactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmfactory.init(trustStore);
sslContext.init(null, tmfactory.getTrustManagers(), null);
return sslContext;
} catch (Exception e) {
throw new RuntimeException(e);
}
}

/**
* Define the Elasticsearch password to set. It enables security behind the scene.
* Define the Elasticsearch password to set. It enables security behind the scene for major version below 8.0.0.
* It's not possible to use security with the oss image.
* @param password Password to set
* @return this
Expand All @@ -92,7 +163,10 @@ public ElasticsearchContainer withPassword(String password) {
"Please switch to the default distribution");
}
withEnv("ELASTIC_PASSWORD", password);
withEnv("xpack.security.enabled", "true");
if (!isAtLeastMajorVersion8) {
// major version 8 is secure by default and does not need this to enable authentication
withEnv("xpack.security.enabled", "true");
}
return this;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
package org.testcontainers.elasticsearch;

import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.rnorth.visibleassertions.VisibleAssertions.assertThrows;

import java.io.IOException;
import org.apache.http.HttpHost;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
Expand All @@ -25,6 +19,13 @@
import org.junit.Test;
import org.testcontainers.utility.DockerImageName;

import java.io.IOException;

import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.rnorth.visibleassertions.VisibleAssertions.assertThrows;

public class ElasticsearchContainerTest {

/**
Expand Down Expand Up @@ -249,6 +250,33 @@ public void incompatibleSettingsTest() {
);
}

@Test
public void testElasticsearch8SecureByDefault() throws Exception {
try (ElasticsearchContainer container = new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:8.0.0")) {
// Start the container. This step might take some time...
container.start();

// Create the secured client.
final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(AuthScope.ANY,
new UsernamePasswordCredentials(ELASTICSEARCH_USERNAME, ElasticsearchContainer.ELASTICSEARCH_DEFAULT_PASSWORD));

client = RestClient.builder(HttpHost.create("https://" + container.getHttpHostAddress()))
.setHttpClientConfigCallback(httpClientBuilder -> {
httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider);
httpClientBuilder.setSSLContext(container.createSslContextFromCa());
return httpClientBuilder;
})
.build();

Response response = client.performRequest(new Request("GET", "/_cluster/health"));
// }}
assertThat(response.getStatusLine().getStatusCode(), is(200));
assertThat(EntityUtils.toString(response.getEntity()), containsString("cluster_name"));
// httpClientSecuredContainer {{
spinscale marked this conversation as resolved.
Show resolved Hide resolved
}
}

private RestClient getClient(ElasticsearchContainer container) {
if (client == null) {
final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
Expand Down