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
   }