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);