Add SSL/TLS support for OpenSearch connections Add support for connecting to OpenSearch clusters with TLS enabled, the default security configuration for OpenSearch. SSL wiring in OpenRestClientProvider.configureHttpClientBuilder(), only when `trustStorePath` is set in the [opensearch] section of gerrit.config or defaults to Gerrit's own `httpd.sslKeyStore`. OpenSearchSslIT verifies SSL connectivity against a security-enabled OpenSearch container. Change-Id: Ib3f0f07a63e188256a7f8bdf91fc58e4d3442786
diff --git a/BUILD b/BUILD index 59aa918..c955c67 100644 --- a/BUILD +++ b/BUILD
@@ -111,7 +111,7 @@ name = "index-opensearch_tests", size = "small", srcs = glob( - ["src/test/java/**/*Test.java"], + ["src/test/java/**/*IT.java", "src/test/java/**/*Test.java"], exclude = ["src/test/java/**/Open*Query*" + SUFFIX], ), tags = ["opensearch"],
diff --git a/src/main/java/com/google/gerrit/opensearch/OpenSearchConfiguration.java b/src/main/java/com/google/gerrit/opensearch/OpenSearchConfiguration.java index 1053bd3..6495799 100644 --- a/src/main/java/com/google/gerrit/opensearch/OpenSearchConfiguration.java +++ b/src/main/java/com/google/gerrit/opensearch/OpenSearchConfiguration.java
@@ -21,16 +21,19 @@ import com.google.gerrit.index.IndexConfig; import com.google.gerrit.index.PaginationType; import com.google.gerrit.server.config.GerritServerConfig; +import com.google.gerrit.server.config.SitePaths; import com.google.inject.Inject; import com.google.inject.ProvisionException; import com.google.inject.Singleton; import java.net.URI; import java.net.URISyntaxException; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; import org.apache.hc.core5.http.HttpHost; import org.eclipse.jgit.lib.Config; +import org.eclipse.jgit.util.FS; @Singleton public class OpenSearchConfiguration { @@ -47,6 +50,8 @@ static final String KEY_CODEC = "codec"; static final String KEY_CONNECT_TIMEOUT = "connectTimeout"; static final String KEY_SOCKET_TIMEOUT = "socketTimeout"; + static final String KEY_TRUST_STORE_PATH = "trustStorePath"; + static final String KEY_TRUST_STORE_PASSWORD = "trustStorePassword"; static final String DEFAULT_CODEC = "default"; static final String DEFAULT_PORT = "9200"; @@ -69,15 +74,28 @@ final int connectTimeout; final int socketTimeout; final String prefix; + final Path trustStorePath; + final String trustStorePassword; @Inject - OpenSearchConfiguration(@GerritServerConfig Config cfg, IndexConfig indexConfig) { + OpenSearchConfiguration( + @GerritServerConfig Config cfg, IndexConfig indexConfig, SitePaths sitePaths) { if (PaginationType.NONE == indexConfig.paginationType()) { throw new ProvisionException( "The 'index.paginationType = NONE' configuration is not supported by OpenSearch"); } this.cfg = cfg; + this.trustStorePath = + cfg.getPath( + SECTION_OPENSEARCH, + null, + KEY_TRUST_STORE_PATH, + FS.DETECTED, + sitePaths.site_path.toFile(), + sitePaths.resolve( + firstNonNull(cfg.getString("httpd", null, "sslKeyStore"), "etc/keystore"))); + this.trustStorePassword = cfg.getString(SECTION_OPENSEARCH, null, KEY_TRUST_STORE_PASSWORD); this.password = cfg.getString(SECTION_OPENSEARCH, null, KEY_PASSWORD); this.username = password == null
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md index fb08ac5..712d379 100644 --- a/src/main/resources/Documentation/config.md +++ b/src/main/resources/Documentation/config.md
@@ -108,6 +108,22 @@ Defaults to `default`. +### opensearch.trustStorePath + +Path to a Java truststore containing the CA certificate used to verify +the OpenSearch server's TLS certificate. If not absolute, the path is +resolved relative to `$site_path`. + +Defaults to `httpd.sslKeyStore` value, consistent with other Gerrit plugins +that make outbound TLS connections. If the file does not exist, the JVM's +default truststore is used. + +### opensearch.trustStorePassword + +Password for the truststore specified in `opensearch.trustStorePath`. + +Not set by default. + [Back to @PLUGIN@ documentation index][index] [index]: index.html \ No newline at end of file
diff --git a/src/test/java/com/google/gerrit/opensearch/Container.java b/src/test/java/com/google/gerrit/opensearch/Container.java index 71c3dd5..aa1409a 100644 --- a/src/test/java/com/google/gerrit/opensearch/Container.java +++ b/src/test/java/com/google/gerrit/opensearch/Container.java
@@ -36,6 +36,21 @@ return container; } + public static Container createAndStart(OpenSearchVersion version, boolean securityEnabled) { + Container container = new Container(version); + if (securityEnabled) { + container.withSecurityEnabled(); + } + try { + container.start(); + } catch (ContainerLaunchException e) { + logger.atSevere().log( + "Failed to launch OpenSearch container. Logs from container:\n%s", container.getLogs()); + throw e; + } + return container; + } + private static DockerImageName getImageName(OpenSearchVersion version) { DockerImageName image = DockerImageName.parse("opensearchproject/opensearch"); switch (version) {
diff --git a/src/test/java/com/google/gerrit/opensearch/ContainerRestClientProvider.java b/src/test/java/com/google/gerrit/opensearch/ContainerRestClientProvider.java index 2405480..07a1575 100644 --- a/src/test/java/com/google/gerrit/opensearch/ContainerRestClientProvider.java +++ b/src/test/java/com/google/gerrit/opensearch/ContainerRestClientProvider.java
@@ -16,9 +16,12 @@ import com.google.inject.Inject; import com.google.inject.Singleton; +import java.io.IOException; +import java.nio.file.Files; import java.security.KeyManagementException; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; import javax.net.ssl.SSLContext; import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager; @@ -30,20 +33,26 @@ @Singleton class ContainerRestClientProvider extends RestClientProvider { + private final OpenSearchConfiguration cfg; private final Container container; @Inject ContainerRestClientProvider(OpenSearchConfiguration cfg, Container container) { super(cfg); + this.cfg = cfg; this.container = container; } @Override protected void configureHttpClientBuilder(HttpAsyncClientBuilder httpClientBuilder) { - if (container.isSecurityEnabled()) { + if (cfg.trustStorePath != null && Files.exists(cfg.trustStorePath)) { try { SSLContext sslContext = - SSLContextBuilder.create().loadTrustMaterial(null, (chains, authType) -> true).build(); + SSLContextBuilder.create() + .loadTrustMaterial( + cfg.trustStorePath.toFile(), + cfg.trustStorePassword != null ? cfg.trustStorePassword.toCharArray() : null) + .build(); TlsStrategy tlsStrategy = ClientTlsStrategyBuilder.create() .setSslContext(sslContext) @@ -54,7 +63,11 @@ PoolingAsyncClientConnectionManager connectionManager = PoolingAsyncClientConnectionManagerBuilder.create().setTlsStrategy(tlsStrategy).build(); httpClientBuilder.setConnectionManager(connectionManager); - } catch (NoSuchAlgorithmException | KeyManagementException | KeyStoreException e) { + } catch (NoSuchAlgorithmException + | IOException + | KeyManagementException + | CertificateException + | KeyStoreException e) { throw new IllegalStateException("Failed to create SSL context for test container", e); } }
diff --git a/src/test/java/com/google/gerrit/opensearch/OpenSearchConfigurationTest.java b/src/test/java/com/google/gerrit/opensearch/OpenSearchConfigurationTest.java index 02f228a..2e81bdb 100644 --- a/src/test/java/com/google/gerrit/opensearch/OpenSearchConfigurationTest.java +++ b/src/test/java/com/google/gerrit/opensearch/OpenSearchConfigurationTest.java
@@ -19,6 +19,8 @@ import static com.google.gerrit.opensearch.OpenSearchConfiguration.KEY_PASSWORD; import static com.google.gerrit.opensearch.OpenSearchConfiguration.KEY_PREFIX; import static com.google.gerrit.opensearch.OpenSearchConfiguration.KEY_SERVER; +import static com.google.gerrit.opensearch.OpenSearchConfiguration.KEY_TRUST_STORE_PASSWORD; +import static com.google.gerrit.opensearch.OpenSearchConfiguration.KEY_TRUST_STORE_PATH; import static com.google.gerrit.opensearch.OpenSearchConfiguration.KEY_USERNAME; import static com.google.gerrit.opensearch.OpenSearchConfiguration.SECTION_OPENSEARCH; import static com.google.gerrit.testing.GerritJUnit.assertThrows; @@ -26,17 +28,24 @@ import com.google.common.collect.ImmutableList; import com.google.gerrit.index.IndexConfig; +import com.google.gerrit.server.config.SitePaths; import com.google.inject.ProvisionException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; import java.util.Arrays; import org.apache.hc.core5.http.HttpHost; import org.eclipse.jgit.lib.Config; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; public class OpenSearchConfigurationTest { @Test public void singleServerNoOtherConfig() throws Exception { Config cfg = newConfig(); - OpenSearchConfiguration esCfg = newOpenConfig(cfg); + OpenSearchConfiguration esCfg = newOpenSearchConfig(cfg); assertHosts(esCfg, "http://open:1234"); assertThat(esCfg.username).isNull(); assertThat(esCfg.password).isNull(); @@ -47,7 +56,7 @@ public void serverWithoutPortSpecified() throws Exception { Config cfg = new Config(); cfg.setString(SECTION_OPENSEARCH, null, KEY_SERVER, "http://open"); - OpenSearchConfiguration esCfg = newOpenConfig(cfg); + OpenSearchConfiguration esCfg = newOpenSearchConfig(cfg); assertHosts(esCfg, "http://open:9200"); } @@ -55,7 +64,7 @@ public void prefix() throws Exception { Config cfg = newConfig(); cfg.setString(SECTION_OPENSEARCH, null, KEY_PREFIX, "myprefix"); - OpenSearchConfiguration esCfg = newOpenConfig(cfg); + OpenSearchConfiguration esCfg = newOpenSearchConfig(cfg); assertThat(esCfg.prefix).isEqualTo("myprefix"); } @@ -64,7 +73,7 @@ Config cfg = newConfig(); cfg.setString(SECTION_OPENSEARCH, null, KEY_USERNAME, "myself"); cfg.setString(SECTION_OPENSEARCH, null, KEY_PASSWORD, "s3kr3t"); - OpenSearchConfiguration esCfg = newOpenConfig(cfg); + OpenSearchConfiguration esCfg = newOpenSearchConfig(cfg); assertThat(esCfg.username).isEqualTo("myself"); assertThat(esCfg.password).isEqualTo("s3kr3t"); } @@ -73,7 +82,7 @@ public void withAuthenticationPasswordOnlyUsesDefaultUsername() throws Exception { Config cfg = newConfig(); cfg.setString(SECTION_OPENSEARCH, null, KEY_PASSWORD, "s3kr3t"); - OpenSearchConfiguration esCfg = newOpenConfig(cfg); + OpenSearchConfiguration esCfg = newOpenSearchConfig(cfg); assertThat(esCfg.username).isEqualTo(DEFAULT_USERNAME); assertThat(esCfg.password).isEqualTo("s3kr3t"); } @@ -86,7 +95,7 @@ null, KEY_SERVER, ImmutableList.of("http://open1:1234", "http://open2:1234")); - OpenSearchConfiguration esCfg = newOpenConfig(cfg); + OpenSearchConfiguration esCfg = newOpenSearchConfig(cfg); assertHosts(esCfg, "http://open1:1234", "http://open2:1234"); } @@ -107,7 +116,7 @@ Config cfg = new Config(); cfg.setStringList( SECTION_OPENSEARCH, null, KEY_SERVER, ImmutableList.of("http://open1:1234", "foo")); - OpenSearchConfiguration esCfg = newOpenConfig(cfg); + OpenSearchConfiguration esCfg = newOpenSearchConfig(cfg); assertHosts(esCfg, "http://open1:1234"); } @@ -119,14 +128,39 @@ cfg, "The 'index.paginationType = NONE' configuration is not supported by OpenSearch"); } + @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + + @Test + public void trustStorePathIsReadFromConfig() throws Exception { + // Generate a throwaway truststore in a temp dir + Path trustStore = tempFolder.newFile("test.jks").toPath(); + KeyStore ks = KeyStore.getInstance("JKS"); + ks.load(null, null); + try (OutputStream os = Files.newOutputStream(trustStore)) { + ks.store(os, "changeit".toCharArray()); + } + + Config cfg = new Config(); + cfg.setString(SECTION_OPENSEARCH, null, KEY_SERVER, "http://localhost:9200"); + cfg.setString(SECTION_OPENSEARCH, null, KEY_TRUST_STORE_PATH, trustStore.toString()); + cfg.setString(SECTION_OPENSEARCH, null, KEY_TRUST_STORE_PASSWORD, "changeit"); + + OpenSearchConfiguration openCfg = newOpenSearchConfig(cfg); + assertThat(openCfg.trustStorePath).isEqualTo(trustStore); + assertThat(openCfg.trustStorePassword).isEqualTo("changeit"); + } + private static Config newConfig() { Config config = new Config(); config.setString(SECTION_OPENSEARCH, null, KEY_SERVER, "http://open:1234"); return config; } - private static OpenSearchConfiguration newOpenConfig(Config cfg) { - return new OpenSearchConfiguration(cfg, IndexConfig.fromConfig(cfg).build()); + private OpenSearchConfiguration newOpenSearchConfig(Config cfg) throws Exception { + return new OpenSearchConfiguration( + cfg, + IndexConfig.fromConfig(cfg).build(), + new SitePaths(tempFolder.newFolder("site").toPath())); } private void assertHosts(OpenSearchConfiguration cfg, Object... hostURIs) throws Exception { @@ -135,7 +169,8 @@ } private void assertProvisionException(Config cfg, String msg) { - ProvisionException thrown = assertThrows(ProvisionException.class, () -> newOpenConfig(cfg)); + ProvisionException thrown = + assertThrows(ProvisionException.class, () -> newOpenSearchConfig(cfg)); assertThat(thrown).hasMessageThat().contains(msg); } }
diff --git a/src/test/java/com/google/gerrit/opensearch/OpenSearchSslIT.java b/src/test/java/com/google/gerrit/opensearch/OpenSearchSslIT.java new file mode 100644 index 0000000..8374ac5 --- /dev/null +++ b/src/test/java/com/google/gerrit/opensearch/OpenSearchSslIT.java
@@ -0,0 +1,49 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.opensearch; + +import static com.google.common.truth.Truth.assertThat; + +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +public class OpenSearchSslIT { + private static Container container; + private static CloseableHttpClient client; + + @BeforeClass + public static void startContainer() { + container = Container.createAndStart(OpenSearchVersion.V3, true); + client = OpenSearchTestUtils.createHttpClient(container); + } + + @AfterClass + public static void stopContainer() { + if (container != null) { + container.close(); + } + } + + @Test + public void canConnectToSecurityEnabledOpenSearch() throws Exception { + assertThat(container.isSecurityEnabled()).isTrue(); + ClassicHttpResponse response = client.execute(new HttpGet(container.getHttpHostAddress())); + assertThat(response.getCode()).isEqualTo(200); + } +}
diff --git a/src/test/java/com/google/gerrit/opensearch/OpenSearchTestUtils.java b/src/test/java/com/google/gerrit/opensearch/OpenSearchTestUtils.java index fffd003..c6fd5b2 100644 --- a/src/test/java/com/google/gerrit/opensearch/OpenSearchTestUtils.java +++ b/src/test/java/com/google/gerrit/opensearch/OpenSearchTestUtils.java
@@ -26,14 +26,30 @@ import com.google.inject.Injector; import com.google.inject.Key; import com.google.inject.TypeLiteral; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; import java.util.Collection; import java.util.UUID; +import javax.net.ssl.SSLContext; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder; import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.HttpStatus; import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.ssl.SSLContextBuilder; import org.eclipse.jgit.lib.Config; public final class OpenSearchTestUtils { @@ -105,6 +121,33 @@ } public static CloseableHttpClient createHttpClient(Container container) { + if (container.isSecurityEnabled()) { + try { + KeyStore trustStore = extractTrustStore(container); + SSLContext sslContext = + SSLContextBuilder.create().loadTrustMaterial(trustStore, null).build(); + SSLConnectionSocketFactory sslSocketFactory = + SSLConnectionSocketFactoryBuilder.create() + .setSslContext(sslContext) + .setHostnameVerifier(NoopHostnameVerifier.INSTANCE) + .build(); + PoolingHttpClientConnectionManager connectionManager = + PoolingHttpClientConnectionManagerBuilder.create() + .setSSLSocketFactory(sslSocketFactory) + .build(); + BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials( + new AuthScope(null, -1), + new UsernamePasswordCredentials( + container.getUsername(), container.getPassword().toCharArray())); + return HttpClients.custom() + .setConnectionManager(connectionManager) + .setDefaultCredentialsProvider(credentialsProvider) + .build(); + } catch (Exception e) { + throw new RuntimeException("Failed to create SSL HTTP client", e); + } + } return HttpClients.custom().build(); } @@ -124,6 +167,22 @@ .isEqualTo(HttpStatus.SC_OK); } + public static KeyStore extractTrustStore(Container container) throws Exception { + Path certFile = Files.createTempFile("opensearch-cert", ".pem"); + container.copyFileFromContainer("/usr/share/opensearch/config/esnode.pem", certFile.toString()); + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + X509Certificate cert; + try (InputStream is = Files.newInputStream(certFile)) { + cert = (X509Certificate) cf.generateCertificate(is); + } + + KeyStore trustStore = KeyStore.getInstance("JKS"); + trustStore.load(null, null); + trustStore.setCertificateEntry("opensearch", cert); + return trustStore; + } + private OpenSearchTestUtils() { // hide default constructor }