Skip to content

Commit c7449ed

Browse files
spinscalekiviewbsideup
authored
Elasticsearch: Ensure Elasticsearch 8 works OOTB secure as default (#5099)
Since Elasticsearch 8.0 the default is to enable security, meaning TLS and authentication. This adds a check for Elasticsearch 8.0 to change the default behaviour to properly support this change, but you can still run Elasticsearch with security features disabled, if you want. Co-authored-by: Kevin Wittek <[email protected]> Co-authored-by: Sergei Egorov <[email protected]>
1 parent 53e80aa commit c7449ed

File tree

2 files changed

+111
-23
lines changed

2 files changed

+111
-23
lines changed

modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
11
package org.testcontainers.elasticsearch;
22

3-
import static java.net.HttpURLConnection.HTTP_OK;
4-
import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;
5-
6-
import java.net.InetSocketAddress;
7-
import java.time.Duration;
3+
import com.github.dockerjava.api.command.InspectContainerResponse;
4+
import org.apache.commons.io.IOUtils;
85
import org.testcontainers.containers.GenericContainer;
9-
import org.testcontainers.containers.wait.strategy.HttpWaitStrategy;
6+
import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy;
107
import org.testcontainers.utility.Base58;
8+
import org.testcontainers.utility.ComparableVersion;
119
import org.testcontainers.utility.DockerImageName;
1210

11+
import javax.net.ssl.SSLContext;
12+
import javax.net.ssl.TrustManagerFactory;
13+
import java.io.ByteArrayInputStream;
14+
import java.net.InetSocketAddress;
15+
import java.security.KeyStore;
16+
import java.security.cert.Certificate;
17+
import java.security.cert.CertificateFactory;
18+
import java.util.Optional;
19+
1320
/**
1421
* Represents an elasticsearch docker instance which exposes by default port 9200 and 9300 (transport.tcp.port)
1522
* The docker image is by default fetched from docker.elastic.co/elasticsearch/elasticsearch
1623
*/
1724
public class ElasticsearchContainer extends GenericContainer<ElasticsearchContainer> {
1825

26+
/**
27+
* Elasticsearch Default Password for Elasticsearch &gt;= 8
28+
*/
29+
public static final String ELASTICSEARCH_DEFAULT_PASSWORD = "changeme";
30+
1931
/**
2032
* Elasticsearch Default HTTP port
2133
*/
@@ -39,7 +51,10 @@ public class ElasticsearchContainer extends GenericContainer<ElasticsearchContai
3951
*/
4052
@Deprecated
4153
protected static final String DEFAULT_TAG = "7.9.2";
42-
private boolean isOss = false;
54+
55+
private final boolean isOss;
56+
private final boolean isAtLeastMajorVersion8;
57+
private Optional<byte[]> caCertAsBytes = Optional.empty();
4358

4459
/**
4560
* @deprecated use {@link ElasticsearchContainer(DockerImageName)} instead
@@ -65,23 +80,67 @@ public ElasticsearchContainer(final DockerImageName dockerImageName) {
6580
super(dockerImageName);
6681

6782
dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, DEFAULT_OSS_IMAGE_NAME);
68-
69-
if (dockerImageName.isCompatibleWith(DEFAULT_OSS_IMAGE_NAME)) {
70-
this.isOss = true;
71-
}
83+
this.isOss = dockerImageName.isCompatibleWith(DEFAULT_OSS_IMAGE_NAME);
7284

7385
logger().info("Starting an elasticsearch container using [{}]", dockerImageName);
7486
withNetworkAliases("elasticsearch-" + Base58.randomString(6));
7587
withEnv("discovery.type", "single-node");
7688
addExposedPorts(ELASTICSEARCH_DEFAULT_PORT, ELASTICSEARCH_DEFAULT_TCP_PORT);
77-
setWaitStrategy(new HttpWaitStrategy()
78-
.forPort(ELASTICSEARCH_DEFAULT_PORT)
79-
.forStatusCodeMatching(response -> response == HTTP_OK || response == HTTP_UNAUTHORIZED)
80-
.withStartupTimeout(Duration.ofMinutes(2)));
89+
this.isAtLeastMajorVersion8 = new ComparableVersion(dockerImageName.getVersionPart()).isGreaterThanOrEqualTo("8.0.0");
90+
// regex that
91+
// matches 8.0 JSON logging with no whitespace between message field and content
92+
// matches 7.x JSON logging with whitespace between message field and content
93+
// matches 6.x text logging with node name in brackets and just a 'started' message till the end of the line
94+
String regex = ".*(\"message\":\\s?\"started\".*|] started\n$)";
95+
setWaitStrategy(new LogMessageWaitStrategy().withRegEx(regex));
96+
if (isAtLeastMajorVersion8) {
97+
withPassword(ELASTICSEARCH_DEFAULT_PASSWORD);
98+
}
99+
}
100+
101+
@Override
102+
protected void containerIsStarted(InspectContainerResponse containerInfo) {
103+
if (isAtLeastMajorVersion8) {
104+
byte[] bytes = copyFileFromContainer("/usr/share/elasticsearch/config/certs/http_ca.crt", IOUtils::toByteArray);
105+
if (bytes.length > 0) {
106+
this.caCertAsBytes = Optional.of(bytes);
107+
}
108+
}
109+
}
110+
111+
/**
112+
* If this is running above Elasticsearch 8, this will return the probably self-signed CA cert that has been extracted
113+
*
114+
* @return byte array optional containing the CA cert extracted from the docker container
115+
*/
116+
public Optional<byte[]> caCertAsBytes() {
117+
return caCertAsBytes;
81118
}
82119

83120
/**
84-
* Define the Elasticsearch password to set. It enables security behind the scene.
121+
* A SSL context based on the self signed CA, so that using this SSL Context allows to connect to the Elasticsearch service
122+
* @return a customized SSL Context
123+
*/
124+
public SSLContext createSslContextFromCa() {
125+
try {
126+
CertificateFactory factory = CertificateFactory.getInstance("X.509");
127+
Certificate trustedCa = factory.generateCertificate(new ByteArrayInputStream(caCertAsBytes.get()));
128+
KeyStore trustStore = KeyStore.getInstance("pkcs12");
129+
trustStore.load(null, null);
130+
trustStore.setCertificateEntry("ca", trustedCa);
131+
132+
final SSLContext sslContext = SSLContext.getInstance("TLSv1.3");
133+
TrustManagerFactory tmfactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
134+
tmfactory.init(trustStore);
135+
sslContext.init(null, tmfactory.getTrustManagers(), null);
136+
return sslContext;
137+
} catch (Exception e) {
138+
throw new RuntimeException(e);
139+
}
140+
}
141+
142+
/**
143+
* Define the Elasticsearch password to set. It enables security behind the scene for major version below 8.0.0.
85144
* It's not possible to use security with the oss image.
86145
* @param password Password to set
87146
* @return this
@@ -92,7 +151,10 @@ public ElasticsearchContainer withPassword(String password) {
92151
"Please switch to the default distribution");
93152
}
94153
withEnv("ELASTIC_PASSWORD", password);
95-
withEnv("xpack.security.enabled", "true");
154+
if (!isAtLeastMajorVersion8) {
155+
// major version 8 is secure by default and does not need this to enable authentication
156+
withEnv("xpack.security.enabled", "true");
157+
}
96158
return this;
97159
}
98160

modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/ElasticsearchContainerTest.java

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
package org.testcontainers.elasticsearch;
22

3-
import static org.hamcrest.CoreMatchers.containsString;
4-
import static org.hamcrest.CoreMatchers.is;
5-
import static org.hamcrest.MatcherAssert.assertThat;
6-
import static org.rnorth.visibleassertions.VisibleAssertions.assertThrows;
7-
8-
import java.io.IOException;
93
import org.apache.http.HttpHost;
104
import org.apache.http.auth.AuthScope;
115
import org.apache.http.auth.UsernamePasswordCredentials;
@@ -25,6 +19,13 @@
2519
import org.junit.Test;
2620
import org.testcontainers.utility.DockerImageName;
2721

22+
import java.io.IOException;
23+
24+
import static org.hamcrest.CoreMatchers.containsString;
25+
import static org.hamcrest.CoreMatchers.is;
26+
import static org.hamcrest.MatcherAssert.assertThat;
27+
import static org.rnorth.visibleassertions.VisibleAssertions.assertThrows;
28+
2829
public class ElasticsearchContainerTest {
2930

3031
/**
@@ -249,6 +250,31 @@ public void incompatibleSettingsTest() {
249250
);
250251
}
251252

253+
@Test
254+
public void testElasticsearch8SecureByDefault() throws Exception {
255+
try (ElasticsearchContainer container = new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:8.0.0")) {
256+
// Start the container. This step might take some time...
257+
container.start();
258+
259+
// Create the secured client.
260+
final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
261+
credentialsProvider.setCredentials(AuthScope.ANY,
262+
new UsernamePasswordCredentials(ELASTICSEARCH_USERNAME, ElasticsearchContainer.ELASTICSEARCH_DEFAULT_PASSWORD));
263+
264+
client = RestClient.builder(HttpHost.create("https://" + container.getHttpHostAddress()))
265+
.setHttpClientConfigCallback(httpClientBuilder -> {
266+
httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider);
267+
httpClientBuilder.setSSLContext(container.createSslContextFromCa());
268+
return httpClientBuilder;
269+
})
270+
.build();
271+
272+
Response response = client.performRequest(new Request("GET", "/_cluster/health"));
273+
assertThat(response.getStatusLine().getStatusCode(), is(200));
274+
assertThat(EntityUtils.toString(response.getEntity()), containsString("cluster_name"));
275+
}
276+
}
277+
252278
private RestClient getClient(ElasticsearchContainer container) {
253279
if (client == null) {
254280
final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();

0 commit comments

Comments
 (0)