From e7a1ce1a2728cd82402fdc5abd1cab5272188ad9 Mon Sep 17 00:00:00 2001
From: Andrew Bell <115623869+andybharness@users.noreply.github.com>
Date: Tue, 7 Mar 2023 19:10:11 +0000
Subject: [PATCH] FFM-7004 - Java SDK - TLS - support custom CAs (#138)
* FFM-7004 - Java SDK - TLS - support custom CAs
What
Allow the SDK user to provide a custom TLS CA via the HarnessConnector API
Why
We currently rely on pre-installed CA bundles in the JDK for TLS connections, e.g. those hostnames signed by public CAs, it's useful to provide alternative methods of loading private TLS CAs for internal on-prem deployments
Testing
Manual - tested against a ff-server proxy with TLS termination enabled
---
examples/pom.xml | 11 +-
.../io/harness/ff/examples/TlsExample.java | 100 ++++++++++++++++++
examples/src/main/resources/log4j.properties | 4 +-
examples/src/main/resources/log4j2.xml | 2 +-
pom.xml | 2 +-
.../cf/client/connector/EventSource.java | 56 +++++++++-
.../cf/client/connector/HarnessConfig.java | 8 ++
.../cf/client/connector/HarnessConnector.java | 41 ++++++-
.../cf/client/connector/EventSourceTest.java | 14 ++-
9 files changed, 220 insertions(+), 18 deletions(-)
create mode 100644 examples/src/main/java/io/harness/ff/examples/TlsExample.java
diff --git a/examples/pom.xml b/examples/pom.xml
index 632ca006..6ccbbcde 100644
--- a/examples/pom.xml
+++ b/examples/pom.xml
@@ -6,7 +6,7 @@
io.harness.featureflags
examples
- 1.1.11
+ 1.2.0
8
@@ -33,7 +33,7 @@
io.harness
ff-java-server-sdk
- 1.1.11
+ 1.2.0
@@ -65,6 +65,13 @@
2.19.0
+
+
+ org.bouncycastle
+ bcpkix-jdk18on
+ 1.72
+
+
diff --git a/examples/src/main/java/io/harness/ff/examples/TlsExample.java b/examples/src/main/java/io/harness/ff/examples/TlsExample.java
new file mode 100644
index 00000000..c3a1522d
--- /dev/null
+++ b/examples/src/main/java/io/harness/ff/examples/TlsExample.java
@@ -0,0 +1,100 @@
+package io.harness.ff.examples;
+
+import io.harness.cf.client.api.BaseConfig;
+import io.harness.cf.client.api.CfClient;
+import io.harness.cf.client.api.FeatureFlagInitializeException;
+import io.harness.cf.client.connector.HarnessConfig;
+import io.harness.cf.client.connector.HarnessConnector;
+import io.harness.cf.client.dto.Target;
+import org.bouncycastle.cert.X509CertificateHolder;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.openssl.PEMParser;
+
+import java.io.FileReader;
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.security.Provider;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import static java.lang.System.out;
+
+public class TlsExample {
+ private static final String apiKey = getEnvOrDefault("FF_API_KEY", "");
+ private static final String flagName = getEnvOrDefault("FF_FLAG_NAME", "harnessappdemodarkmode");
+ private static final String trustedCaPemFile = getEnvOrDefault("FF_TRUSTED_CA_FILE_NAME", "/change/me/CA.pem");
+
+ private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
+ private static final Provider bcProvider = new BouncyCastleProvider();
+
+
+ public static void main(String[] args) throws InterruptedException, FeatureFlagInitializeException, GeneralSecurityException, IOException {
+ out.println("Java SDK TLS example");
+
+ List trustedServers = loadCerts(trustedCaPemFile);
+
+ // Note that this code uses ffserver hostname as an example, likely you'll have your own hostname or IP.
+ // You should ensure the endpoint is returning a cert with valid SANs configured for the host/IP.
+ HarnessConfig config = HarnessConfig.builder()
+ .configUrl("https://ffserver:8001/api/1.0")
+ .eventUrl("https://ffserver:8000/api/1.0")
+ .tlsTrustedCAs(trustedServers)
+ .build();
+
+ HarnessConnector connector = new HarnessConnector(apiKey, config);
+
+ try (CfClient cfClient = new CfClient(connector)) {
+
+ cfClient.waitForInitialization();
+
+ final Target target = Target.builder()
+ .identifier("javasdk")
+ .name("JavaSDK")
+ .build();
+
+ // Loop forever reporting the state of the flag
+ scheduler.scheduleAtFixedRate(
+ () -> {
+ boolean result = cfClient.boolVariation(flagName, target, false);
+ out.println("Flag '" + flagName + "' Boolean variation is " + result);
+ },
+ 0,
+ 10,
+ TimeUnit.SECONDS);
+
+
+ TimeUnit.MINUTES.sleep(15);
+
+ out.println("Cleaning up...");
+ scheduler.shutdownNow();
+ }
+ }
+
+ // Get the value from the environment or return the default
+ private static String getEnvOrDefault(String key, String defaultValue) {
+ String value = System.getenv(key);
+ if (value == null || value.isEmpty()) {
+ return defaultValue;
+ }
+ return value;
+ }
+
+ // Here we're using BC's PKIX lib to convert the PEM to an X.509, you can use any crypto library you prefer
+ private static List loadCerts(String filename) throws IOException, CertificateException {
+ List list = new ArrayList<>();
+ try (PEMParser parser = new PEMParser(new FileReader(filename))) {
+ Object obj;
+ while ((obj = parser.readObject()) != null) {
+ if (obj instanceof X509CertificateHolder) {
+ list.add(new JcaX509CertificateConverter().setProvider(bcProvider).getCertificate((X509CertificateHolder) obj));
+ }
+ }
+ }
+ return list;
+ }
+}
\ No newline at end of file
diff --git a/examples/src/main/resources/log4j.properties b/examples/src/main/resources/log4j.properties
index 01ea98de..dcb90d52 100644
--- a/examples/src/main/resources/log4j.properties
+++ b/examples/src/main/resources/log4j.properties
@@ -1,2 +1,2 @@
-log4j.rootLogger=debug
-log4j.logger.io.harness=debug
+log4j.rootLogger=info
+log4j.logger.io.harness=info
diff --git a/examples/src/main/resources/log4j2.xml b/examples/src/main/resources/log4j2.xml
index 51adb093..8952c91b 100644
--- a/examples/src/main/resources/log4j2.xml
+++ b/examples/src/main/resources/log4j2.xml
@@ -11,7 +11,7 @@
-
+
diff --git a/pom.xml b/pom.xml
index f4fc01fd..02718945 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
io.harness
ff-java-server-sdk
- 1.1.11
+ 1.2.0
jar
Harness Feature Flag Java Server SDK
Harness Feature Flag Java Server SDK
diff --git a/src/main/java/io/harness/cf/client/connector/EventSource.java b/src/main/java/io/harness/cf/client/connector/EventSource.java
index 523d0778..5502fcdb 100644
--- a/src/main/java/io/harness/cf/client/connector/EventSource.java
+++ b/src/main/java/io/harness/cf/client/connector/EventSource.java
@@ -5,9 +5,16 @@
import com.here.oksse.ServerSentEvent;
import io.harness.cf.client.dto.Message;
import io.harness.cf.client.logger.LogUtil;
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
+import java.security.SecureRandom;
+import java.security.cert.X509Certificate;
+import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
+import javax.net.ssl.*;
import lombok.NonNull;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
@@ -36,8 +43,9 @@ public EventSource(
@NonNull String url,
Map headers,
@NonNull Updater updater,
- long sseReadTimeoutMins) {
- this(url, headers, updater, sseReadTimeoutMins, 2_000);
+ long sseReadTimeoutMins)
+ throws ConnectorException {
+ this(url, headers, updater, sseReadTimeoutMins, 2_000, null);
}
EventSource(
@@ -45,10 +53,12 @@ public EventSource(
Map headers,
@NonNull Updater updater,
long sseReadTimeoutMins,
- int retryDelayMs) {
+ int retryDelayMs,
+ List trustedCAs)
+ throws ConnectorException {
this.updater = updater;
this.retryTime = retryDelayMs;
- okSse = new OkSse(makeStreamClient(sseReadTimeoutMins));
+ okSse = new OkSse(makeStreamClient(sseReadTimeoutMins, trustedCAs));
builder = new Request.Builder().url(url);
headers.put("User-Agent", "JavaSDK " + io.harness.cf.Version.VERSION);
headers.forEach(builder::header);
@@ -56,11 +66,15 @@ public EventSource(
log.info("EventSource initialized with url {} and headers {}", url, headers);
}
- protected OkHttpClient makeStreamClient(long sseReadTimeoutMins) {
+ protected OkHttpClient makeStreamClient(long sseReadTimeoutMins, List trustedCAs)
+ throws ConnectorException {
OkHttpClient.Builder httpClientBuilder =
new OkHttpClient.Builder()
.readTimeout(sseReadTimeoutMins, TimeUnit.MINUTES)
.retryOnConnectionFailure(true);
+
+ setupTls(httpClientBuilder, trustedCAs);
+
if (log.isDebugEnabled()) {
loggingInterceptor = new HttpLoggingInterceptor();
loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
@@ -85,6 +99,38 @@ protected OkHttpClient makeStreamClient(long sseReadTimeoutMins) {
return httpClientBuilder.build();
}
+ public boolean throwex = true;
+
+ private void setupTls(OkHttpClient.Builder httpClientBuilder, List trustedCAs)
+ throws ConnectorException {
+
+ try {
+ if (trustedCAs != null && !trustedCAs.isEmpty()) {
+
+ final KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
+ keyStore.load(null, null);
+ for (int i = 0; i < trustedCAs.size(); i++) {
+ keyStore.setCertificateEntry("ca" + i, trustedCAs.get(i));
+ }
+
+ final TrustManagerFactory trustManagerFactory =
+ TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ trustManagerFactory.init(keyStore);
+ final TrustManager[] trustManagers = trustManagerFactory.getTrustManagers();
+
+ final SSLContext sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(null, trustManagers, new SecureRandom());
+
+ httpClientBuilder.sslSocketFactory(
+ sslContext.getSocketFactory(), (X509TrustManager) trustManagers[0]);
+ }
+ } catch (GeneralSecurityException | IOException ex) {
+ String msg = "Failed to setup TLS on SSE endpoint: " + ex.getMessage();
+ log.warn(msg, ex);
+ throw new ConnectorException(msg, true, ex);
+ }
+ }
+
@Override
public void onOpen(ServerSentEvent serverSentEvent, Response response) {
log.info("EventSource onOpen");
diff --git a/src/main/java/io/harness/cf/client/connector/HarnessConfig.java b/src/main/java/io/harness/cf/client/connector/HarnessConfig.java
index d04c0d9a..b1d7d139 100644
--- a/src/main/java/io/harness/cf/client/connector/HarnessConfig.java
+++ b/src/main/java/io/harness/cf/client/connector/HarnessConfig.java
@@ -1,5 +1,7 @@
package io.harness.cf.client.connector;
+import java.security.cert.X509Certificate;
+import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@@ -25,4 +27,10 @@ public class HarnessConfig {
/** read timeout in minutes for SSE connections */
@Builder.Default long sseReadTimeout = 1;
+
+ /**
+ * list of trusted CAs - for when the given config/event URLs are signed with a private CA. You
+ * should include intermediate CAs too to allow the HTTP client to build a full trust chain.
+ */
+ @Builder.Default List tlsTrustedCAs = null;
}
diff --git a/src/main/java/io/harness/cf/client/connector/HarnessConnector.java b/src/main/java/io/harness/cf/client/connector/HarnessConnector.java
index 218fd4a8..fed300a1 100644
--- a/src/main/java/io/harness/cf/client/connector/HarnessConnector.java
+++ b/src/main/java/io/harness/cf/client/connector/HarnessConnector.java
@@ -8,10 +8,12 @@
import io.harness.cf.client.dto.Claim;
import io.harness.cf.client.logger.LogUtil;
import io.harness.cf.model.*;
-import java.io.IOException;
+import java.io.*;
import java.nio.charset.StandardCharsets;
+import java.security.cert.X509Certificate;
import java.util.*;
import lombok.NonNull;
+import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import okhttp3.Interceptor;
import okhttp3.Request;
@@ -51,6 +53,11 @@ public HarnessConnector(@NonNull final String apiKey, @NonNull final HarnessConf
log.info("Connector initialized, with options " + options);
}
+ @SneakyThrows
+ private byte[] certToByteArray(X509Certificate cert) {
+ return cert.getEncoded();
+ }
+
ApiClient makeApiClient(int retryBackOfDelay) {
final ApiClient apiClient = new ApiClient();
apiClient.setBasePath(options.getConfigUrl());
@@ -59,6 +66,9 @@ ApiClient makeApiClient(int retryBackOfDelay) {
apiClient.setWriteTimeout(options.getWriteTimeout());
apiClient.setDebugging(log.isDebugEnabled());
apiClient.setUserAgent("JavaSDK " + io.harness.cf.Version.VERSION);
+
+ setupTls(apiClient);
+
// if http client response is 403 we need to reauthenticate
apiClient.setHttpClient(
apiClient
@@ -94,6 +104,9 @@ ApiClient makeMetricsApiClient(int retryBackoffDelay) {
apiClient.setWriteTimeout(maxTimeout);
apiClient.setDebugging(log.isDebugEnabled());
apiClient.setUserAgent("JavaSDK " + io.harness.cf.Version.VERSION);
+
+ setupTls(apiClient);
+
apiClient.setHttpClient(
apiClient
.getHttpClient()
@@ -313,7 +326,7 @@ public void postMetrics(@NonNull final Metrics metrics) throws ConnectorExceptio
}
@Override
- public Service stream(@NonNull final Updater updater) {
+ public Service stream(@NonNull final Updater updater) throws ConnectorException {
log.debug("Check if eventsource is already initialized");
if (eventSource != null) {
log.debug("EventSource is already initialized, closing ...");
@@ -325,7 +338,14 @@ public Service stream(@NonNull final Updater updater) {
map.put("Authorization", "Bearer " + token);
map.put("API-Key", apiKey);
log.info("Initialize new EventSource instance");
- eventSource = new EventSource(sseUrl, map, updater, Math.max(options.getSseReadTimeout(), 1));
+ eventSource =
+ new EventSource(
+ sseUrl,
+ map,
+ updater,
+ Math.max(options.getSseReadTimeout(), 1),
+ 2_000,
+ options.getTlsTrustedCAs());
return eventSource;
}
@@ -342,6 +362,21 @@ public void close() {
log.debug("connector closed!");
}
+ private void setupTls(ApiClient apiClient) {
+ final List trustedCAs = options.getTlsTrustedCAs();
+ if (trustedCAs != null && !trustedCAs.isEmpty()) {
+
+ // because openapi doesn't take X509 certs directly we need some boilerplate
+ byte[] certsAsBytes =
+ trustedCAs.stream()
+ .map(this::certToByteArray)
+ .collect(ByteArrayOutputStream::new, (s, b) -> s.write(b, 0, b.length), (a, b) -> {})
+ .toByteArray();
+
+ apiClient.setSslCaCert(new ByteArrayInputStream(certsAsBytes));
+ }
+ }
+
/* package private - should not be used outside of tests */
HarnessConnector(
diff --git a/src/test/java/io/harness/cf/client/connector/EventSourceTest.java b/src/test/java/io/harness/cf/client/connector/EventSourceTest.java
index c03f3b70..c2845a3d 100644
--- a/src/test/java/io/harness/cf/client/connector/EventSourceTest.java
+++ b/src/test/java/io/harness/cf/client/connector/EventSourceTest.java
@@ -70,13 +70,18 @@ protected MockResponse makeStreamResponse() {
@Test
void shouldNotCallErrorHandlerIfRetryEventuallyReconnectsToStreamEndpoint()
- throws IOException, InterruptedException {
+ throws IOException, InterruptedException, ConnectorException {
CountingUpdater updater = new CountingUpdater();
try (MockWebServer mockSvr = new MockWebServer();
EventSource eventSource =
new EventSource(
- setupMockServer(mockSvr, new StreamDispatcher()), new HashMap<>(), updater, 1, 1)) {
+ setupMockServer(mockSvr, new StreamDispatcher()),
+ new HashMap<>(),
+ updater,
+ 1,
+ 1,
+ null)) {
eventSource.start();
TimeUnit.SECONDS.sleep(15);
@@ -93,7 +98,7 @@ void shouldNotCallErrorHandlerIfRetryEventuallyReconnectsToStreamEndpoint()
@Test
void shouldRestartPollerIfAllConnectionAttemptsToStreamEndpointFail()
- throws IOException, InterruptedException {
+ throws IOException, InterruptedException, ConnectorException {
CountingUpdater updater = new CountingUpdater();
try (MockWebServer mockSvr = new MockWebServer();
@@ -103,7 +108,8 @@ void shouldRestartPollerIfAllConnectionAttemptsToStreamEndpointFail()
new HashMap<>(),
updater,
1,
- 1)) {
+ 1,
+ null)) {
eventSource.start();
TimeUnit.SECONDS.sleep(15);