Skip to content

Commit e7a1ce1

Browse files
authored
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
1 parent 588e42e commit e7a1ce1

File tree

9 files changed

+220
-18
lines changed

9 files changed

+220
-18
lines changed

examples/pom.xml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>io.harness.featureflags</groupId>
88
<artifactId>examples</artifactId>
9-
<version>1.1.11</version>
9+
<version>1.2.0</version>
1010

1111
<properties>
1212
<maven.compiler.source>8</maven.compiler.source>
@@ -33,7 +33,7 @@
3333
<dependency>
3434
<groupId>io.harness</groupId>
3535
<artifactId>ff-java-server-sdk</artifactId>
36-
<version>1.1.11</version>
36+
<version>1.2.0</version>
3737
</dependency>
3838

3939
<dependency>
@@ -65,6 +65,13 @@
6565
<version>2.19.0</version>
6666
</dependency>
6767

68+
<!-- https://mvnrepository.com/artifact/org.bouncycastle/bcpkix-jdk18on -->
69+
<dependency>
70+
<groupId>org.bouncycastle</groupId>
71+
<artifactId>bcpkix-jdk18on</artifactId>
72+
<version>1.72</version>
73+
</dependency>
74+
6875
</dependencies>
6976

7077

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package io.harness.ff.examples;
2+
3+
import io.harness.cf.client.api.BaseConfig;
4+
import io.harness.cf.client.api.CfClient;
5+
import io.harness.cf.client.api.FeatureFlagInitializeException;
6+
import io.harness.cf.client.connector.HarnessConfig;
7+
import io.harness.cf.client.connector.HarnessConnector;
8+
import io.harness.cf.client.dto.Target;
9+
import org.bouncycastle.cert.X509CertificateHolder;
10+
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
11+
import org.bouncycastle.jce.provider.BouncyCastleProvider;
12+
import org.bouncycastle.openssl.PEMParser;
13+
14+
import java.io.FileReader;
15+
import java.io.IOException;
16+
import java.security.GeneralSecurityException;
17+
import java.security.Provider;
18+
import java.security.cert.CertificateException;
19+
import java.security.cert.X509Certificate;
20+
import java.util.ArrayList;
21+
import java.util.List;
22+
import java.util.concurrent.Executors;
23+
import java.util.concurrent.ScheduledExecutorService;
24+
import java.util.concurrent.TimeUnit;
25+
import static java.lang.System.out;
26+
27+
public class TlsExample {
28+
private static final String apiKey = getEnvOrDefault("FF_API_KEY", "");
29+
private static final String flagName = getEnvOrDefault("FF_FLAG_NAME", "harnessappdemodarkmode");
30+
private static final String trustedCaPemFile = getEnvOrDefault("FF_TRUSTED_CA_FILE_NAME", "/change/me/CA.pem");
31+
32+
private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
33+
private static final Provider bcProvider = new BouncyCastleProvider();
34+
35+
36+
public static void main(String[] args) throws InterruptedException, FeatureFlagInitializeException, GeneralSecurityException, IOException {
37+
out.println("Java SDK TLS example");
38+
39+
List<X509Certificate> trustedServers = loadCerts(trustedCaPemFile);
40+
41+
// Note that this code uses ffserver hostname as an example, likely you'll have your own hostname or IP.
42+
// You should ensure the endpoint is returning a cert with valid SANs configured for the host/IP.
43+
HarnessConfig config = HarnessConfig.builder()
44+
.configUrl("https://ffserver:8001/api/1.0")
45+
.eventUrl("https://ffserver:8000/api/1.0")
46+
.tlsTrustedCAs(trustedServers)
47+
.build();
48+
49+
HarnessConnector connector = new HarnessConnector(apiKey, config);
50+
51+
try (CfClient cfClient = new CfClient(connector)) {
52+
53+
cfClient.waitForInitialization();
54+
55+
final Target target = Target.builder()
56+
.identifier("javasdk")
57+
.name("JavaSDK")
58+
.build();
59+
60+
// Loop forever reporting the state of the flag
61+
scheduler.scheduleAtFixedRate(
62+
() -> {
63+
boolean result = cfClient.boolVariation(flagName, target, false);
64+
out.println("Flag '" + flagName + "' Boolean variation is " + result);
65+
},
66+
0,
67+
10,
68+
TimeUnit.SECONDS);
69+
70+
71+
TimeUnit.MINUTES.sleep(15);
72+
73+
out.println("Cleaning up...");
74+
scheduler.shutdownNow();
75+
}
76+
}
77+
78+
// Get the value from the environment or return the default
79+
private static String getEnvOrDefault(String key, String defaultValue) {
80+
String value = System.getenv(key);
81+
if (value == null || value.isEmpty()) {
82+
return defaultValue;
83+
}
84+
return value;
85+
}
86+
87+
// Here we're using BC's PKIX lib to convert the PEM to an X.509, you can use any crypto library you prefer
88+
private static List<X509Certificate> loadCerts(String filename) throws IOException, CertificateException {
89+
List<X509Certificate> list = new ArrayList<>();
90+
try (PEMParser parser = new PEMParser(new FileReader(filename))) {
91+
Object obj;
92+
while ((obj = parser.readObject()) != null) {
93+
if (obj instanceof X509CertificateHolder) {
94+
list.add(new JcaX509CertificateConverter().setProvider(bcProvider).getCertificate((X509CertificateHolder) obj));
95+
}
96+
}
97+
}
98+
return list;
99+
}
100+
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
log4j.rootLogger=debug
2-
log4j.logger.io.harness=debug
1+
log4j.rootLogger=info
2+
log4j.logger.io.harness=info

examples/src/main/resources/log4j2.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
</Appenders>
1212

1313
<Loggers>
14-
<Root level="debug">
14+
<Root level="info">
1515
<AppenderRef ref="console"/>
1616
</Root>
1717
</Loggers>

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>io.harness</groupId>
88
<artifactId>ff-java-server-sdk</artifactId>
9-
<version>1.1.11</version>
9+
<version>1.2.0</version>
1010
<packaging>jar</packaging>
1111
<name>Harness Feature Flag Java Server SDK</name>
1212
<description>Harness Feature Flag Java Server SDK</description>

src/main/java/io/harness/cf/client/connector/EventSource.java

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,16 @@
55
import com.here.oksse.ServerSentEvent;
66
import io.harness.cf.client.dto.Message;
77
import io.harness.cf.client.logger.LogUtil;
8+
import java.io.IOException;
9+
import java.security.GeneralSecurityException;
10+
import java.security.KeyStore;
11+
import java.security.SecureRandom;
12+
import java.security.cert.X509Certificate;
13+
import java.util.List;
814
import java.util.Map;
915
import java.util.UUID;
1016
import java.util.concurrent.TimeUnit;
17+
import javax.net.ssl.*;
1118
import lombok.NonNull;
1219
import lombok.SneakyThrows;
1320
import lombok.extern.slf4j.Slf4j;
@@ -36,31 +43,38 @@ public EventSource(
3643
@NonNull String url,
3744
Map<String, String> headers,
3845
@NonNull Updater updater,
39-
long sseReadTimeoutMins) {
40-
this(url, headers, updater, sseReadTimeoutMins, 2_000);
46+
long sseReadTimeoutMins)
47+
throws ConnectorException {
48+
this(url, headers, updater, sseReadTimeoutMins, 2_000, null);
4149
}
4250

4351
EventSource(
4452
@NonNull String url,
4553
Map<String, String> headers,
4654
@NonNull Updater updater,
4755
long sseReadTimeoutMins,
48-
int retryDelayMs) {
56+
int retryDelayMs,
57+
List<X509Certificate> trustedCAs)
58+
throws ConnectorException {
4959
this.updater = updater;
5060
this.retryTime = retryDelayMs;
51-
okSse = new OkSse(makeStreamClient(sseReadTimeoutMins));
61+
okSse = new OkSse(makeStreamClient(sseReadTimeoutMins, trustedCAs));
5262
builder = new Request.Builder().url(url);
5363
headers.put("User-Agent", "JavaSDK " + io.harness.cf.Version.VERSION);
5464
headers.forEach(builder::header);
5565
updater.onReady();
5666
log.info("EventSource initialized with url {} and headers {}", url, headers);
5767
}
5868

59-
protected OkHttpClient makeStreamClient(long sseReadTimeoutMins) {
69+
protected OkHttpClient makeStreamClient(long sseReadTimeoutMins, List<X509Certificate> trustedCAs)
70+
throws ConnectorException {
6071
OkHttpClient.Builder httpClientBuilder =
6172
new OkHttpClient.Builder()
6273
.readTimeout(sseReadTimeoutMins, TimeUnit.MINUTES)
6374
.retryOnConnectionFailure(true);
75+
76+
setupTls(httpClientBuilder, trustedCAs);
77+
6478
if (log.isDebugEnabled()) {
6579
loggingInterceptor = new HttpLoggingInterceptor();
6680
loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
@@ -85,6 +99,38 @@ protected OkHttpClient makeStreamClient(long sseReadTimeoutMins) {
8599
return httpClientBuilder.build();
86100
}
87101

102+
public boolean throwex = true;
103+
104+
private void setupTls(OkHttpClient.Builder httpClientBuilder, List<X509Certificate> trustedCAs)
105+
throws ConnectorException {
106+
107+
try {
108+
if (trustedCAs != null && !trustedCAs.isEmpty()) {
109+
110+
final KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
111+
keyStore.load(null, null);
112+
for (int i = 0; i < trustedCAs.size(); i++) {
113+
keyStore.setCertificateEntry("ca" + i, trustedCAs.get(i));
114+
}
115+
116+
final TrustManagerFactory trustManagerFactory =
117+
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
118+
trustManagerFactory.init(keyStore);
119+
final TrustManager[] trustManagers = trustManagerFactory.getTrustManagers();
120+
121+
final SSLContext sslContext = SSLContext.getInstance("TLS");
122+
sslContext.init(null, trustManagers, new SecureRandom());
123+
124+
httpClientBuilder.sslSocketFactory(
125+
sslContext.getSocketFactory(), (X509TrustManager) trustManagers[0]);
126+
}
127+
} catch (GeneralSecurityException | IOException ex) {
128+
String msg = "Failed to setup TLS on SSE endpoint: " + ex.getMessage();
129+
log.warn(msg, ex);
130+
throw new ConnectorException(msg, true, ex);
131+
}
132+
}
133+
88134
@Override
89135
public void onOpen(ServerSentEvent serverSentEvent, Response response) {
90136
log.info("EventSource onOpen");

src/main/java/io/harness/cf/client/connector/HarnessConfig.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package io.harness.cf.client.connector;
22

3+
import java.security.cert.X509Certificate;
4+
import java.util.List;
35
import lombok.AllArgsConstructor;
46
import lombok.Builder;
57
import lombok.Getter;
@@ -25,4 +27,10 @@ public class HarnessConfig {
2527

2628
/** read timeout in minutes for SSE connections */
2729
@Builder.Default long sseReadTimeout = 1;
30+
31+
/**
32+
* list of trusted CAs - for when the given config/event URLs are signed with a private CA. You
33+
* should include intermediate CAs too to allow the HTTP client to build a full trust chain.
34+
*/
35+
@Builder.Default List<X509Certificate> tlsTrustedCAs = null;
2836
}

src/main/java/io/harness/cf/client/connector/HarnessConnector.java

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
import io.harness.cf.client.dto.Claim;
99
import io.harness.cf.client.logger.LogUtil;
1010
import io.harness.cf.model.*;
11-
import java.io.IOException;
11+
import java.io.*;
1212
import java.nio.charset.StandardCharsets;
13+
import java.security.cert.X509Certificate;
1314
import java.util.*;
1415
import lombok.NonNull;
16+
import lombok.SneakyThrows;
1517
import lombok.extern.slf4j.Slf4j;
1618
import okhttp3.Interceptor;
1719
import okhttp3.Request;
@@ -51,6 +53,11 @@ public HarnessConnector(@NonNull final String apiKey, @NonNull final HarnessConf
5153
log.info("Connector initialized, with options " + options);
5254
}
5355

56+
@SneakyThrows
57+
private byte[] certToByteArray(X509Certificate cert) {
58+
return cert.getEncoded();
59+
}
60+
5461
ApiClient makeApiClient(int retryBackOfDelay) {
5562
final ApiClient apiClient = new ApiClient();
5663
apiClient.setBasePath(options.getConfigUrl());
@@ -59,6 +66,9 @@ ApiClient makeApiClient(int retryBackOfDelay) {
5966
apiClient.setWriteTimeout(options.getWriteTimeout());
6067
apiClient.setDebugging(log.isDebugEnabled());
6168
apiClient.setUserAgent("JavaSDK " + io.harness.cf.Version.VERSION);
69+
70+
setupTls(apiClient);
71+
6272
// if http client response is 403 we need to reauthenticate
6373
apiClient.setHttpClient(
6474
apiClient
@@ -94,6 +104,9 @@ ApiClient makeMetricsApiClient(int retryBackoffDelay) {
94104
apiClient.setWriteTimeout(maxTimeout);
95105
apiClient.setDebugging(log.isDebugEnabled());
96106
apiClient.setUserAgent("JavaSDK " + io.harness.cf.Version.VERSION);
107+
108+
setupTls(apiClient);
109+
97110
apiClient.setHttpClient(
98111
apiClient
99112
.getHttpClient()
@@ -313,7 +326,7 @@ public void postMetrics(@NonNull final Metrics metrics) throws ConnectorExceptio
313326
}
314327

315328
@Override
316-
public Service stream(@NonNull final Updater updater) {
329+
public Service stream(@NonNull final Updater updater) throws ConnectorException {
317330
log.debug("Check if eventsource is already initialized");
318331
if (eventSource != null) {
319332
log.debug("EventSource is already initialized, closing ...");
@@ -325,7 +338,14 @@ public Service stream(@NonNull final Updater updater) {
325338
map.put("Authorization", "Bearer " + token);
326339
map.put("API-Key", apiKey);
327340
log.info("Initialize new EventSource instance");
328-
eventSource = new EventSource(sseUrl, map, updater, Math.max(options.getSseReadTimeout(), 1));
341+
eventSource =
342+
new EventSource(
343+
sseUrl,
344+
map,
345+
updater,
346+
Math.max(options.getSseReadTimeout(), 1),
347+
2_000,
348+
options.getTlsTrustedCAs());
329349
return eventSource;
330350
}
331351

@@ -342,6 +362,21 @@ public void close() {
342362
log.debug("connector closed!");
343363
}
344364

365+
private void setupTls(ApiClient apiClient) {
366+
final List<X509Certificate> trustedCAs = options.getTlsTrustedCAs();
367+
if (trustedCAs != null && !trustedCAs.isEmpty()) {
368+
369+
// because openapi doesn't take X509 certs directly we need some boilerplate
370+
byte[] certsAsBytes =
371+
trustedCAs.stream()
372+
.map(this::certToByteArray)
373+
.collect(ByteArrayOutputStream::new, (s, b) -> s.write(b, 0, b.length), (a, b) -> {})
374+
.toByteArray();
375+
376+
apiClient.setSslCaCert(new ByteArrayInputStream(certsAsBytes));
377+
}
378+
}
379+
345380
/* package private - should not be used outside of tests */
346381

347382
HarnessConnector(

0 commit comments

Comments
 (0)