From ddcd6fce905118b276ac10d37965734b837f6830 Mon Sep 17 00:00:00 2001 From: Rob Rudin Date: Thu, 26 Jan 2023 11:41:26 -0500 Subject: [PATCH] Enhancements for connecting to ML Cloud These fixes are driven by testing against the ReverseProxyServer which can now emulate ML Cloud. The fixes: 1. If user chooses an sslProtocol of "default", then the JVM's default trust manager will be used as well. 2. If the user uses `withMarkLogicCloudAuth`, then the SSL protocol will default to "default". 3. The call to "/token" in ML Cloud now uses the user-provided SSL hostname verifier (this was not required on the ML Cloud instance I tested with, but that is not guaranteed to be true). --- .../client/DatabaseClientBuilder.java | 10 +- .../impl/DatabaseClientPropertySource.java | 17 +- .../com/marklogic/client/impl/SSLUtil.java | 45 ++++++ ...arkLogicCloudAuthenticationConfigurer.java | 1 + .../client/impl/okhttp/OkHttpUtil.java | 30 ++-- .../test/DatabaseClientBuilderTest.java | 30 ++++ .../MarkLogicCloudAuthenticationDebugger.java | 13 +- test-app/build.gradle | 14 ++ test-app/gradle.properties | 5 + .../client/test/ReverseProxyServer.java | 153 +++++++++++++++--- test-app/src/main/resources/README.md | 10 ++ test-app/src/main/resources/keystore.jks | Bin 3088 -> 0 bytes test-app/src/main/resources/logback.xml | 17 ++ .../src/main/resources/selfsigned-cert.pem | 21 +++ test-app/src/main/resources/selfsigned.jks | Bin 0 -> 2607 bytes 15 files changed, 313 insertions(+), 53 deletions(-) create mode 100644 marklogic-client-api/src/main/java/com/marklogic/client/impl/SSLUtil.java create mode 100644 test-app/src/main/resources/README.md delete mode 100644 test-app/src/main/resources/keystore.jks create mode 100644 test-app/src/main/resources/logback.xml create mode 100644 test-app/src/main/resources/selfsigned-cert.pem create mode 100644 test-app/src/main/resources/selfsigned.jks diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/DatabaseClientBuilder.java b/marklogic-client-api/src/main/java/com/marklogic/client/DatabaseClientBuilder.java index 298153f38..9498ce2ff 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/DatabaseClientBuilder.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/DatabaseClientBuilder.java @@ -143,9 +143,17 @@ public DatabaseClientBuilder withDigestAuth(String username, String password) { } public DatabaseClientBuilder withMarkLogicCloudAuth(String apiKey, String basePath) { - return withSecurityContextType(SECURITY_CONTEXT_TYPE_MARKLOGIC_CLOUD) + withSecurityContextType(SECURITY_CONTEXT_TYPE_MARKLOGIC_CLOUD) .withCloudApiKey(apiKey) .withBasePath(basePath); + + // Assume sensible defaults for establishing an SSL connection. In the scenario where the user's JVM's + // truststore has a certificate matching that of the MarkLogic Cloud instance, this saves the user from having + // to configure anything except the API key and base path. + if (null == props.get(PREFIX + "sslProtocol") && null == props.get(PREFIX + "sslContext")) { + withSSLProtocol("default"); + } + return this; } public DatabaseClientBuilder withKerberosAuth(String principal) { diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/impl/DatabaseClientPropertySource.java b/marklogic-client-api/src/main/java/com/marklogic/client/impl/DatabaseClientPropertySource.java index 04b1aebb1..5d12d89c5 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/impl/DatabaseClientPropertySource.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/impl/DatabaseClientPropertySource.java @@ -18,6 +18,8 @@ import com.marklogic.client.DatabaseClient; import com.marklogic.client.DatabaseClientBuilder; import com.marklogic.client.DatabaseClientFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.X509TrustManager; @@ -37,7 +39,8 @@ */ public class DatabaseClientPropertySource { - private final static String PREFIX = DatabaseClientBuilder.PREFIX; + private static final Logger logger = LoggerFactory.getLogger(DatabaseClientPropertySource.class); + private static final String PREFIX = DatabaseClientBuilder.PREFIX; private final Function propertySource; @@ -226,6 +229,18 @@ private X509TrustManager determineTrustManager() { throw new IllegalArgumentException( String.format("Trust manager must be an instance of %s", X509TrustManager.class.getName())); } + // If the user chooses the "default" SSLContext, then it's already been initialized - but OkHttp still + // needs a separate X509TrustManager, so use the JVM's default trust manager. The assumption is that the + // default SSLContext was initialized with the JVM's default trust manager. A user can of course always override + // this by simply providing their own trust manager. + if ("default".equalsIgnoreCase((String) propertySource.apply(PREFIX + "sslProtocol"))) { + X509TrustManager defaultTrustManager = SSLUtil.getDefaultTrustManager(); + if (logger.isDebugEnabled() && defaultTrustManager != null && defaultTrustManager.getAcceptedIssuers() != null) { + logger.debug("Count of accepted issuers in default trust manager: {}", + defaultTrustManager.getAcceptedIssuers().length); + } + return defaultTrustManager; + } return null; } diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/impl/SSLUtil.java b/marklogic-client-api/src/main/java/com/marklogic/client/impl/SSLUtil.java new file mode 100644 index 000000000..f3f24e9c6 --- /dev/null +++ b/marklogic-client-api/src/main/java/com/marklogic/client/impl/SSLUtil.java @@ -0,0 +1,45 @@ +package com.marklogic.client.impl; + +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; + +public interface SSLUtil { + + static X509TrustManager getDefaultTrustManager() { + return (X509TrustManager) getDefaultTrustManagers()[0]; + } + + /** + * @return a non-empty array of TrustManager instances based on the JVM's default trust manager algorithm, with the + * first trust manager guaranteed to be an instance of X509TrustManager. + */ + static TrustManager[] getDefaultTrustManagers() { + final String defaultAlgorithm = TrustManagerFactory.getDefaultAlgorithm(); + TrustManagerFactory trustManagerFactory; + try { + trustManagerFactory = TrustManagerFactory.getInstance(defaultAlgorithm); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Unable to obtain trust manager factory using JVM's default trust manager algorithm: " + defaultAlgorithm, e); + } + + try { + trustManagerFactory.init((KeyStore) null); + } catch (KeyStoreException e) { + throw new RuntimeException("Unable to initialize trust manager factory obtained using JVM's default trust manager algorithm: " + defaultAlgorithm + + "; cause: " + e.getMessage(), e); + } + + TrustManager[] trustManagers = trustManagerFactory.getTrustManagers(); + if (trustManagers == null || trustManagers.length == 0) { + throw new RuntimeException("No trust managers found using the JVM's default trust manager algorithm: " + defaultAlgorithm); + } + if (!(trustManagers[0] instanceof X509TrustManager)) { + throw new RuntimeException("Default trust manager is not an X509TrustManager: " + trustManagers[0]); + } + return trustManagers; + } +} diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/MarkLogicCloudAuthenticationConfigurer.java b/marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/MarkLogicCloudAuthenticationConfigurer.java index 1993f1a2e..d2c0e9dc8 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/MarkLogicCloudAuthenticationConfigurer.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/MarkLogicCloudAuthenticationConfigurer.java @@ -61,6 +61,7 @@ private Response callTokenEndpoint(MarkLogicCloudAuthContext securityContext) { // Current assumption is that the SSL config provided for connecting to MarkLogic should also be applicable // for connecting to MarkLogic Cloud's "/token" endpoint. OkHttpUtil.configureSocketFactory(clientBuilder, securityContext.getSSLContext(), securityContext.getTrustManager()); + OkHttpUtil.configureHostnameVerifier(clientBuilder, securityContext.getSSLHostnameVerifier()); if (logger.isInfoEnabled()) { logger.info("Calling token endpoint at: " + tokenUrl); diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/OkHttpUtil.java b/marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/OkHttpUtil.java index 3691e7e43..08a5998a4 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/OkHttpUtil.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/OkHttpUtil.java @@ -3,6 +3,7 @@ import com.marklogic.client.DatabaseClientFactory; import com.marklogic.client.impl.HTTPKerberosAuthInterceptor; import com.marklogic.client.impl.HTTPSamlAuthInterceptor; +import com.marklogic.client.impl.SSLUtil; import okhttp3.ConnectionPool; import okhttp3.CookieJar; import okhttp3.Dns; @@ -13,15 +14,11 @@ import javax.net.ssl.HostnameVerifier; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; import java.net.Inet4Address; import java.net.InetAddress; import java.net.UnknownHostException; import java.security.KeyManagementException; -import java.security.KeyStore; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -123,7 +120,7 @@ private static void configureSAMLAuth(DatabaseClientFactory.SAMLAuthContext saml * @param clientBuilder * @param sslVerifier */ - private static void configureHostnameVerifier(OkHttpClient.Builder clientBuilder, DatabaseClientFactory.SSLHostnameVerifier sslVerifier) { + static void configureHostnameVerifier(OkHttpClient.Builder clientBuilder, DatabaseClientFactory.SSLHostnameVerifier sslVerifier) { HostnameVerifier hostnameVerifier = null; if (DatabaseClientFactory.SSLHostnameVerifier.ANY.equals(sslVerifier)) { hostnameVerifier = (hostname, session) -> true; @@ -169,25 +166,16 @@ static void configureSocketFactory(OkHttpClient.Builder clientBuilder, SSLContex * @param sslContext */ private static void initializeSslContext(OkHttpClient.Builder clientBuilder, SSLContext sslContext) { + TrustManager[] trustManagers = SSLUtil.getDefaultTrustManagers(); try { - TrustManagerFactory trustMgrFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - trustMgrFactory.init((KeyStore) null); - TrustManager[] trustMgrs = trustMgrFactory.getTrustManagers(); - if (trustMgrs == null || trustMgrs.length == 0) { - throw new IllegalArgumentException("no trust manager and could not get default trust manager"); - } - if (!(trustMgrs[0] instanceof X509TrustManager)) { - throw new IllegalArgumentException("no trust manager and default is not an X509TrustManager"); - } - sslContext.init(null, trustMgrs, null); - clientBuilder.sslSocketFactory(new SSLSocketFactoryDelegator(sslContext.getSocketFactory()), (X509TrustManager) trustMgrs[0]); - } catch (KeyStoreException e) { - throw new IllegalArgumentException("no trust manager and cannot initialize factory for default", e); - } catch (NoSuchAlgorithmException e) { - throw new IllegalArgumentException("no trust manager and no algorithm for default manager", e); + // In a future release, we may want to check if getSocketFactory() works already, implying that the + // SSLContext has already been initialized. However, if that's the case, then it's not guaranteed that + // the default trust manager is the appropriate one to pass to OkHttp. + sslContext.init(null, trustManagers, null); } catch (KeyManagementException e) { - throw new IllegalArgumentException("no trust manager and cannot initialize context with default", e); + throw new RuntimeException("Unable to initialize SSLContext; cause: " + e.getMessage(), e); } + clientBuilder.sslSocketFactory(new SSLSocketFactoryDelegator(sslContext.getSocketFactory()), (X509TrustManager) trustManagers[0]); } static class DnsImpl implements Dns { diff --git a/marklogic-client-api/src/test/java/com/marklogic/client/test/DatabaseClientBuilderTest.java b/marklogic-client-api/src/test/java/com/marklogic/client/test/DatabaseClientBuilderTest.java index 5efac7a6f..6b40468bd 100644 --- a/marklogic-client-api/src/test/java/com/marklogic/client/test/DatabaseClientBuilderTest.java +++ b/marklogic-client-api/src/test/java/com/marklogic/client/test/DatabaseClientBuilderTest.java @@ -107,6 +107,15 @@ void cloudWithBasePath() { (DatabaseClientFactory.MarkLogicCloudAuthContext) bean.getSecurityContext(); assertEquals("my-key", context.getKey()); assertEquals("/my/path", bean.getBasePath()); + + assertNotNull(context.getSSLContext(), "If no sslProtocol or sslContext is set, the JVM's default SSL " + + "context should be used"); + + assertNotNull(context.getSSLContext().getSocketFactory(), "Since the JVM's default SSL context is expected " + + "to be used, it should already be initialized, and thus able to return a socket factory"); + + assertNotNull(context.getTrustManager(), "Since the JVM's default SSL context is used, the JVM's default " + + "trust manager should be used as well if the user doesn't provide their own"); } @Test @@ -174,6 +183,27 @@ void sslProtocol() { "initialize the SSLContext before using it by using the JVM's default trust manager."); } + @Test + void defaultSslProtocolAndNoTrustManager() { + bean = Common.newClientBuilder() + .withSSLProtocol("default") + .buildBean(); + + DatabaseClientFactory.SecurityContext context = bean.getSecurityContext(); + assertNotNull(context); + + SSLContext sslContext = context.getSSLContext(); + assertNotNull(sslContext); + assertNotNull(sslContext.getSocketFactory(), "A protocol of 'default' should result in the JVM's default " + + "SSLContext being used, which is expected to have been initialized already and can thus return a socket " + + "factory"); + + assertNotNull(context.getTrustManager(), "If the user specifies a protocol of 'default' but does not " + + "provide a trust manager, the assumption is that the JVM's default trust manager should be used, thus " + + "saving the user from having to do the work of providing this themselves."); + } + + @Test void invalidSslProtocol() { RuntimeException ex = assertThrows(RuntimeException.class, () -> Common.newClientBuilder() diff --git a/marklogic-client-api/src/test/java/com/marklogic/client/test/MarkLogicCloudAuthenticationDebugger.java b/marklogic-client-api/src/test/java/com/marklogic/client/test/MarkLogicCloudAuthenticationDebugger.java index ec87ef597..a157cfeb1 100644 --- a/marklogic-client-api/src/test/java/com/marklogic/client/test/MarkLogicCloudAuthenticationDebugger.java +++ b/marklogic-client-api/src/test/java/com/marklogic/client/test/MarkLogicCloudAuthenticationDebugger.java @@ -5,13 +5,13 @@ import com.marklogic.client.DatabaseClientFactory; import com.marklogic.client.io.JacksonHandle; -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; - /** * We don't yet have a way to run tests against a MarkLogic Cloud instance. In the meantime, this program and its * related Gradle task can be used for easy manual testing. + * + * For local testing against the ReverseProxyServer in the test-app project, which emulates MarkLogic Cloud, use + * "localhost" as the cloud host, "username:password" (often "admin:the admin password") as the apiKey, and + * "local/manage" as the basePath. */ public class MarkLogicCloudAuthenticationDebugger { @@ -20,11 +20,12 @@ public static void main(String[] args) throws Exception { String apiKey = args[1]; String basePath = args[2]; + // Expected to default to the JVM's default SSL context and default trust manager DatabaseClient client = new DatabaseClientBuilder() .withHost(cloudHost) .withMarkLogicCloudAuth(apiKey, basePath) - .withSSLContext(SSLContext.getDefault()) - .withTrustManager(Common.TRUST_ALL_MANAGER) + // Have to use "ANY", as the default is "COMMON", which won't work for our selfsigned cert + .withSSLHostnameVerifier(DatabaseClientFactory.SSLHostnameVerifier.ANY) .build(); DatabaseClient.ConnectionResult result = client.checkConnection(); diff --git a/test-app/build.gradle b/test-app/build.gradle index ba5ade8eb..75b88da53 100644 --- a/test-app/build.gradle +++ b/test-app/build.gradle @@ -8,6 +8,10 @@ dependencies { implementation "io.undertow:undertow-core:2.2.21.Final" implementation "io.undertow:undertow-servlet:2.2.21.Final" implementation "com.marklogic:ml-javaclient-util:4.4.0" + implementation 'org.slf4j:slf4j-api:1.7.36' + implementation 'ch.qos.logback:logback-classic:1.3.5' + implementation "com.fasterxml.jackson.core:jackson-databind:2.14.1" + implementation 'com.squareup.okhttp3:okhttp:4.10.0' } // See https://github.com/psxpaul/gradle-execfork-plugin for docs. @@ -22,3 +26,13 @@ task runReverseProxyServer(type: com.github.psxpaul.task.JavaExecFork, dependsOn standardOutput = file("$buildDir/reverse-proxy.log") errorOutput = file("$buildDir/reverse-proxy-error.log") } + +task runBlockingReverseProxyServer(type: JavaExec) { + description = "Run the reverse proxy server so that it blocks and waits for requests; use ctrl-C to stop it. " + + "This is intended for manual testing with the reverse proxy server. If you wish to enable an HTTPS port that is 443" + + "or any value less than 1024, you will need to use sudo to run this - e.g. " + + "sudo ./gradlew runBlockingReverseProxyServer -PrpsHttpsPort=443 ." + classpath = sourceSets.main.runtimeClasspath + main = "com.marklogic.client.test.ReverseProxyServer" + args = [rpsMarkLogicServer, rpsProxyServer, rpsHttpPort, rpsHttpsPort] +} diff --git a/test-app/gradle.properties b/test-app/gradle.properties index 23121168b..cef566178 100644 --- a/test-app/gradle.properties +++ b/test-app/gradle.properties @@ -6,3 +6,8 @@ mlRestPort=8012 mlContentDatabaseName=java-unittest mlTdeValidationEnabled=false mlForestsPerHost=java-functest,2,java-unittest,3 + +rpsMarkLogicServer=localhost +rpsProxyServer=localhost +rpsHttpPort=8020 +rpsHttpsPort=0 diff --git a/test-app/src/main/java/com/marklogic/client/test/ReverseProxyServer.java b/test-app/src/main/java/com/marklogic/client/test/ReverseProxyServer.java index 67f91fd44..c783139f0 100644 --- a/test-app/src/main/java/com/marklogic/client/test/ReverseProxyServer.java +++ b/test-app/src/main/java/com/marklogic/client/test/ReverseProxyServer.java @@ -1,5 +1,8 @@ package com.marklogic.client.test; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.marklogic.client.ext.helper.LoggingObject; import com.marklogic.client.ext.modulesloader.ssl.SimpleX509TrustManager; import io.undertow.Undertow; import io.undertow.client.ClientCallback; @@ -7,10 +10,15 @@ import io.undertow.client.UndertowClient; import io.undertow.server.HttpServerExchange; import io.undertow.server.ServerConnection; +import io.undertow.server.handlers.BlockingHandler; +import io.undertow.server.handlers.form.FormData; +import io.undertow.server.handlers.form.FormParserFactory; import io.undertow.server.handlers.proxy.ProxyCallback; import io.undertow.server.handlers.proxy.ProxyClient; import io.undertow.server.handlers.proxy.ProxyConnection; import io.undertow.server.handlers.proxy.ProxyHandler; +import io.undertow.util.Headers; +import okhttp3.Credentials; import org.springframework.core.io.ClassPathResource; import org.xnio.IoUtils; import org.xnio.OptionMap; @@ -20,9 +28,10 @@ import javax.net.ssl.TrustManager; import java.io.IOException; import java.net.URI; +import java.nio.charset.Charset; import java.security.KeyStore; import java.util.Date; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -34,14 +43,27 @@ *

* Note that this does not yet support digest authentication, which seems to be common with reverse proxy servers. * That's fine for testing the Java Client, as verifying that the basePath works is not related to what kind of - * authentication is required by MarkLogic. + * authentication is required by MarkLogic. But you'll need to ensure that any MarkLogic app server you proxy is using + * either basic or digestbasic authentication. + *

+ * As of 2023-01-26, this can now emulate MarkLogic Cloud. It exposes a "/token" endpoint that proxies to port 8022, + * which this server listens to as well (currently hardcoded). A fake access token is returned. Subsequent requests + * convert that fake access token into a basic authentication value that is included in the proxied request to + * MarkLogic. */ -public class ReverseProxyServer { +public class ReverseProxyServer extends LoggingObject { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final String FAKE_ACCESS_TOKEN_INDICATOR = "FAKE_RPS_TOKEN:"; /** - * Accepts up to 3 args: 1) the MarkLogic hostname to proxy to; 2) the hostname for this server; - * 3) the port for this server. For current use cases though, including Jenkins, localhost should suffice for both - * hostnames and 8020 should suffice as the port. + * Accepts up to 4 args: 1) the MarkLogic hostname to proxy to; 2) the hostname for this server; + * 3) the port for this server; 4) the port for the secure (HTTPS) server. For current use cases though, including + * Jenkins, localhost should suffice for both hostnames and 8020 should suffice as the port. + *

+ * If you wish to enable a secure server on port 443 - i.e. you're looking to emulate MarkLogic Cloud - you'll + * need to run this program as root. Check the build.gradle file for this project to see an example of how to do + * that via Gradle. * * @param args * @throws Exception @@ -50,7 +72,7 @@ public static void main(final String[] args) throws Exception { String markLogicHost = "localhost"; String serverHost = "localhost"; int serverPort = 8020; - int secureServerPort = 8021; + int secureServerPort = 0; if (args.length > 0) { markLogicHost = args[0]; @@ -58,54 +80,118 @@ public static void main(final String[] args) throws Exception { serverHost = args[1]; if (args.length > 2) { serverPort = Integer.parseInt(args[2]); + if (args.length > 3) { + secureServerPort = Integer.parseInt(args[3]); + } } } } + new ReverseProxyServer(markLogicHost, serverHost, serverPort, secureServerPort); + } + + public ReverseProxyServer(String markLogicHost, String serverHost, int serverPort, int secureServerPort) throws Exception { + logger.info("MarkLogic host: {}", markLogicHost); + logger.info("Proxy server host: {}", serverHost); + logger.info("Proxy server HTTP port: {}", serverPort); + logger.info("Proxy server HTTPS port: {}", secureServerPort); + // Set up the mapping of paths to MarkLogic ports. Paths with and without forward slashes are used to ensure // both work properly. - Map mapping = new HashMap<>(); - mapping.put("/test/marklogic/unit", new URI("http://" + markLogicHost + ":8012")); + Map mapping = new LinkedHashMap<>(); + mapping.put("/test/marklogic/unit", new URI(String.format("http://%s:8012", markLogicHost))); // 8014 is for fast functional tests. Slow tests are not yet proxied, which currently will be difficult as they // use a variety of ports. Best approach will be to convert them over to fast tests that all use 8014. - mapping.put("/testFunctional", new URI("http://" + markLogicHost + ":8014")); - System.out.println("Proxy mapping: " + mapping); + mapping.put("/testFunctional", new URI(String.format("http://%s:8014", markLogicHost))); + + // Generic mappings for standard MarkLogic app servers + mapping.put("/local/app-services", new URI(String.format("http://%s:8000", markLogicHost))); + mapping.put("/local/admin", new URI(String.format("http://%s:8001", markLogicHost))); + mapping.put("/local/manage", new URI(String.format("http://%s:8002", markLogicHost))); - Undertow.builder() + // Generic mappings for DHF app servers + mapping.put("/data-hub/staging", new URI(String.format("http://%s:8010", markLogicHost))); + mapping.put("/data-hub/final", new URI(String.format("http://%s:8011", markLogicHost))); + mapping.put("/data-hub/jobs", new URI(String.format("http://%s:8013", markLogicHost))); + + // Emulate MarkLogic Cloud "/token" requests by mapping to the handler defined below that can respond to + // these requests in a suitable fashion for manual testing. + mapping.put("/token", new URI(String.format("http://%s:8022", serverHost))); + + mapping.entrySet().forEach(entry -> { + logger.info("Mapped: " + entry.getKey() + " : " + entry.getValue()); + }); + + Undertow.Builder undertowBuilder = Undertow.builder() .addHttpListener(serverPort, serverHost) - .addHttpsListener(secureServerPort, serverHost, buildSSLContext()) + .addHttpListener(8022, serverHost, new BlockingHandler(exchange -> handleMarkLogicCloudTokenRequest(exchange))) .setIoThreads(4) .setHandler(ProxyHandler.builder() .setProxyClient(new ReverseProxyClient(mapping)) .setMaxRequestTime(30000) .build() - ) + ); + + if (secureServerPort > 0) { + logger.info("Adding an HTTPS listener on port: " + secureServerPort + "; note that if this port 443 " + + "or any value less than 1024, you will need to run this process as root.:"); + undertowBuilder.addHttpsListener(secureServerPort, serverHost, buildSSLContext()); + } else { + logger.info("Not adding an HTTPS listener; to enable one, ensure that the 4th command line argument to " + + "this program is an integer."); + } + + undertowBuilder .build() .start(); } /** - * Tried to enable SSL so that CheckSSLConnectionTest would succeed, but no luck so far. This is using a test - * keystore (copied from the marklogic-nifi project). Undertow seems to accept the request and then forward it - * along, but the test gets a 403. It seems that the basic authentication is not being sent correctly, as testing - * via a web browser results in the authentication being rejected. + * This emulates how MarkLogic Cloud works with a twist - it expects the user's API key to match the pattern + * "username:password". It then generates a basic authentication value for this username/password and returns that + * as the access token. The replaceFakeMarkLogicCloudHeaderIfNecessary method in ReverseProxyClient will then + * replace this fake access token with an appropriate basic authentication header value. + * + * @param exchange + */ + private void handleMarkLogicCloudTokenRequest(HttpServerExchange exchange) { + try { + logger.info("Emulating MarkLogic Cloud and handling /token request"); + FormData formData = FormParserFactory.builder().build().createParser(exchange).parseBlocking(); + String apiKey = formData.getFirst("key").getValue(); + String[] tokens = apiKey.split(":"); + String basicAuthValue = Credentials.basic(tokens[0], tokens[1], Charset.forName("UTF-8")); + ObjectNode response = objectMapper.createObjectNode().put("access_token", FAKE_ACCESS_TOKEN_INDICATOR + basicAuthValue); + exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "application/json"); + exchange.getResponseSender().send(response.toPrettyString()); + } catch (Exception ex) { + System.err.println("Unable to process MarkLogic Cloud token request: " + ex.getMessage()); + } + } + + /** + * Constructs an SSLContext based on the src/main/resources/selfsigned.jks keystore. See the README in that + * directory for more information on the keystore along with instructions for importing the certificate into your + * JVM truststore. * * @return * @throws Exception */ - private static SSLContext buildSSLContext() throws Exception { - final String keyStorePassword = "passwordpassword"; + private SSLContext buildSSLContext() throws Exception { + final String keyStorePassword = "password"; KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); - keyStore.load(new ClassPathResource("keystore.jks").getInputStream(), keyStorePassword.toCharArray()); + keyStore.load(new ClassPathResource("selfsigned.jks").getInputStream(), keyStorePassword.toCharArray()); kmf.init(keyStore, keyStorePassword.toCharArray()); SSLContext sslContext = SSLContext.getInstance("TLSv1.2"); + // Use a "trust-everything" approach for now; the client doesn't have to do this, but we don't have a use case + // yet for having our reverse proxy server validate certificates. sslContext.init(kmf.getKeyManagers(), new TrustManager[]{new SimpleX509TrustManager()}, null); return sslContext; } - private static class ReverseProxyClient implements ProxyClient { + private static class ReverseProxyClient extends LoggingObject implements ProxyClient { private static final ProxyTarget TARGET = new ProxyTarget() { }; @@ -125,9 +211,11 @@ public ProxyTarget findTarget(HttpServerExchange exchange) { @Override public void getConnection(ProxyTarget target, HttpServerExchange exchange, ProxyCallback callback, long timeout, TimeUnit timeUnit) { final String requestURI = exchange.getRequestURI(); - System.out.println("Received request: " + new Date() + "; " + requestURI); + logger.info("Received request: " + requestURI); URI targetUri = null; + replaceFakeMarkLogicCloudHeaderIfNecessary(exchange); + for (String path : mapping.keySet()) { if (requestURI.startsWith(path)) { targetUri = mapping.get(path); @@ -140,7 +228,7 @@ public void getConnection(ProxyTarget target, HttpServerExchange exchange, Proxy throw new IllegalArgumentException("Unsupported request URI: " + exchange.getRequestURI()); } - System.out.println("Proxying to: " + targetUri + exchange.getRequestURI()); + logger.info("Proxying to: " + targetUri + exchange.getRequestURI()); client.connect( new ConnectNotifier(callback, exchange), targetUri, @@ -149,6 +237,23 @@ public void getConnection(ProxyTarget target, HttpServerExchange exchange, Proxy OptionMap.EMPTY); } + /** + * Checks to see if the request has the fake MarkLogic Cloud authentication token in it, which is inserted + * by the "/token" handler. If so, that token is replaced with a basic authentication value, which requires that + * the MarkLogic server use basic or digestbasic authentication. + * + * @param exchange + */ + private void replaceFakeMarkLogicCloudHeaderIfNecessary(HttpServerExchange exchange) { + final String auth = exchange.getRequestHeaders().getFirst("Authorization"); + final String fakeBearerIndicator = "Bearer " + FAKE_ACCESS_TOKEN_INDICATOR; + if (auth != null && auth.startsWith(fakeBearerIndicator)) { + String basicAuthValue = auth.substring(fakeBearerIndicator.length()); + logger.info("Replacing fake MarkLogic Cloud Authorization header with a basic Authorization header: " + basicAuthValue); + exchange.getRequestHeaders().put(Headers.AUTHORIZATION, basicAuthValue); + } + } + private final class ConnectNotifier implements ClientCallback { private final ProxyCallback callback; private final HttpServerExchange exchange; diff --git a/test-app/src/main/resources/README.md b/test-app/src/main/resources/README.md new file mode 100644 index 000000000..9c60c08e3 --- /dev/null +++ b/test-app/src/main/resources/README.md @@ -0,0 +1,10 @@ +The selfsigned.jks file in this directory is used for enabling SSL in the ReverseProxyServer program. It is a simple +PKCS12 keystore with a single self-signed certificate in it named "selfsigned". A copy of that certificate is stored +in this directory as well with the name "selfsigned-cert.pem". + +To import the selfsigned certificate into your JVM truststore, run the following command, replacing "changeit" with +the correct password for your JVM's jssecacerts or cacerts file (check your JVM's jre/lib/security file to see which +one exists; also note that "changeit" may be correct since that's the default password for a cacerts file): + + keytool -importcert -file selfsigned-cert.pem -alias selfsigned -storepass changeit + diff --git a/test-app/src/main/resources/keystore.jks b/test-app/src/main/resources/keystore.jks deleted file mode 100644 index 246fe888efbb981188c9dcca109b5d2c52f2b435..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3088 zcmb`|c{tSF9suz9%_a@TzDvU}%JBY1_7`DFP1&+#U$ZAATQ9;`LzV{F%6MfdJ87|v zE2AhhmLf7Oyo_p0MWJw2_jzyc^W6K~`^Wpod7kq<=RDu@$M<}`XY<|WI|u{<-5$XA zvE!7#kH1Qw_qpu_?~N4xO$Y=51D%~@XpW4zpN#`sJCe8iw3r=g#nrL- zc}e<${}VaCYCr4gf{ezT(R=D z-nlpW#qF(FTF*1$2O#WiubmF;Hrxmy6*igNRlfI9l~@uN!qhs~FEEGiMBlUNgZzPX(j)?E&4g~kNMFchr_X$68d zLt}BE%T`bPS@41`N-iwN`9RK`z@zznQIT?}DVA}T$?N6wuhG{w2?-ZIV8Z4AapH~> zM=w+HO5Fp%YPU&g%=71;K{*z_W9CqOV7L|boJ98Vakn zj}6I7pIDmnvrHDGH%#ao7SjJ1`5HddZo{Zee{!RGW`+H+BrM;}xmtv)l}OxJAZh8- zwT`Bb9j|;Yw_=HDi5fgOoK;}HBaAL{SGgCDyYgC&a%im9w+!Rh4OO{I+3JX2BP;2q`9{!Jli?d3(E_2u<*?$NuwN zAx~K%jw`&zdYQkM#CBOGN@Z)7Q<2%226`=Tiu8=+T0z;V zY7d2carE2qJ_kfpxZ~T^YKhz^<)mJ)c!*1P+p8$2xI%eB(zQC(9^*_&h|gtE3?{fC_^JA?rpHl7SEY8Zz?j!0+^9w##-C~jZ9C0k?>zlY?s;}AZqkO zbydnL6y`L;WU);zfw*aVg|DuDb_E^dVtr1h)_rk&z2RQwX?`5pO;@!2(%#Elv zUe?<*C&k;IN?al00~>7(D+{R{ic4NlN$k7d`Kle9?n&9GU&+1B-px6N;$i0b^mLdH z$(AH9DJAw4%+ES<-~96R++imu41fUT@mIn4Vo+wg1TuVZQMj;q`m}DIxpU)D>FgW# zCt@$))iSz4*>BtOaB)yHPFQ#tq@;HhsC0~}gtS`Jb~GUw{o7yR_5m~iY{B6$C~Otv z{uT?tp&;Z(Y6Z9`D2&{({aqpuTrlXLGvG&Rfp4kF|9${JO@A)o_WRi`ApkLd%?d`^ zwVRHvkb+>ylrv{t<84w-ewm=TU#?Km9_vmc&9~O%6%D7U^XiNP=!ZoKGTfS%Z}Nw) zH{x9_j=9q}jQmOY)74O+sC%~#z>AO)@0IaZlNPG}wW`Bg=)g&(tXNyq_!yt+OrN=~ zI($%TXQXiyc7nf@_hT*hgCy0khUrx^=WzesaX3$8g63@cB7^&p{McFHy1vdY==?h9 zr$i9-K5UdfLvmB==xlIx+U1VV&1Bu2@vaB97}@x`Igcs&i$SY;of|i20_+_wEhLNk z+=yMjh0Gcl{PF_zdC}bg6b%M;P1(`c(n3>q$pDAw=cgoWJ+UFRdnX}(x-;8$N7{Br z_{kkw;BRtA_^UgDhJ(-XdZNP~%U{gf7FCwHzJh|Rf_+h9s``swy%q}DLj3H6C zm&5jubG)kdY)^pIY2viya-LOlTTWE{Q#6J~$`{9ISBVxD5%5QXmr|5=0xtV6gXHrVgG0f?qugFkf{fs5ABY4YFOs`kuIZEzZ*rUX}v9X zN><>P7`!CrHo!+=rw-Pd*t`9=BZ6as?F1UlV0w}EQMEuBMHopBcmt14|g&sQmlf^%aznq zUn5%e{qm(@k9XT@{BRcw#{5+cunF?~P=f$r+me1V`5(#sPm|wG|95zQ?aSX?xh;m} z?PMiEdK%D&u3H6GI^rhnY*xAGv<;Iqj>{WtWW@EZletfeN9l*o^*{aEh^`J6S`PPx zb!lA+afuH^Y<;QMQN8n<1{6v(4GYP(NR)PaHRS}EU0EA{9Fv&6>jAFDwB;aj&{_Ji z%^1H5<93JiC5io0Rj&h9D-N^eGlyk6Ijv?(^|B%=o%YPcg*(0Cu1 diff --git a/test-app/src/main/resources/logback.xml b/test-app/src/main/resources/logback.xml new file mode 100644 index 000000000..bef5b50a6 --- /dev/null +++ b/test-app/src/main/resources/logback.xml @@ -0,0 +1,17 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + diff --git a/test-app/src/main/resources/selfsigned-cert.pem b/test-app/src/main/resources/selfsigned-cert.pem new file mode 100644 index 000000000..89d97eae8 --- /dev/null +++ b/test-app/src/main/resources/selfsigned-cert.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDfTCCAmWgAwIBAgIEZeRCVzANBgkqhkiG9w0BAQsFADBvMQswCQYDVQQGEwJV +UzELMAkGA1UECBMCQ0ExEzARBgNVBAcTClNhbiBDYXJsb3MxEjAQBgNVBAoTCU1h +cmtMb2dpYzEUMBIGA1UECxMLRW5naW5lZXJpbmcxFDASBgNVBAMTC0phdmEgQ2xp +ZW50MB4XDTIzMDEyNTIzNDQxMVoXDTI0MDEyNTIzNDQxMVowbzELMAkGA1UEBhMC +VVMxCzAJBgNVBAgTAkNBMRMwEQYDVQQHEwpTYW4gQ2FybG9zMRIwEAYDVQQKEwlN +YXJrTG9naWMxFDASBgNVBAsTC0VuZ2luZWVyaW5nMRQwEgYDVQQDEwtKYXZhIENs +aWVudDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAIsHFzVqYDMH+4Ki +S925HvIiTKaRHCoGbhWjFiT4ylrzFOrY5k0lWh5kgLepktB6iXJvT9e51FjT3vml +IGZ6oA4hHbVMP996T5EE93HDcQdBrLRFlpZM+1Fwk3J7rrioqn0e2cbuo85JB2MV +/fDKV+7zVnIQwR+LWoFZ1k77qVCnHdCzVTjvP5T/8Ai89wWOZqZS6mWzv3QrNl7g +hx0M3Sco8yCy+hr8HpDgcdjNg8U+d+AJG4tCPV/J4MMnq2SQsSyGKCqTZK4b83Ht +301mG8MbqhGyKeatO3gapRsX4rTG5gAF8qu0abjTm5Tz2/5DCC3/rtSNvGtz0+z6 +WzVcP3kCAwEAAaMhMB8wHQYDVR0OBBYEFMRrfbYE6YgqKuwR1yrzrlsaTG8GMA0G +CSqGSIb3DQEBCwUAA4IBAQA80rGFKydWQ+q4wHkdIIt3llEgj2nUP81pp45iN3iU +u8nTLO9iWFOXG0esyZVTgF17r166i0pWTcchtI0MSe8JsqS8VmH8MCXZTBKpg4P6 +43jLAHmriPTnD8U4jL8aB+7PMWCWembiMifIk8kFE09Vl6L+emdYCeK1Zy8jut/f +awGLrMyMRQGBRd5vdiWC+bDvDeGVK6RyKMQHMhylKO8PeHNDdnnUW6m9HV6VkeH8 +l5Yt12gPim2h6yJrdaOX1sReu8/dwVopBpoqM/iDusRm6/apW6Oi45UbkuhZCPvP +GkUQxOaZweyI4XabBEX9HTJW2WAXfw04tAFrs59MpspP +-----END CERTIFICATE----- diff --git a/test-app/src/main/resources/selfsigned.jks b/test-app/src/main/resources/selfsigned.jks new file mode 100644 index 0000000000000000000000000000000000000000..3221eb58da00c4fa51950244ca5d4b1618a38c69 GIT binary patch literal 2607 zcmY+EcQhP`7RJYz8BwE6L>rwLZAK?b#A381qFcQW5-oz!GD;#rqK)2Luu&o-h~B%I zjV?NgDA9J5XzRW6-tO5y?)lFBzI*RE_n#jEOC*MSoW*EEs z$dP4LuT5tiMs+6r$k|A>7f251G6Z2k{TCB4a+96-VUNv(O}!wv9vEW>^2x$n-9!u+ z1QzIcd{n>BOM80cnr7m6=BbdVKdfcGtj{kqW)bnbjDF}#hXYelbJFA%PL-BHYXZZB zrS$VzIwwVP8N$Rna-p03U&6C^@VKe$+t`Yb&h^a*!On)@*@5UY*Gx3?@^+H%BSruA zBS+Fn#c-$y5%$(qKQq4_O7b=HS2wA35efB#EAbXKZ4il3oxHM&`|r0c%9JkNs4x*r z)mTZu_(d9t_yLEm!09Wqqu)X9AkIBNejD)P3irksj)1`wj`9;&lWQmSLt!55f5I7Ut_GBL zSdi5zoH9Kc4fvHQPn3{+efHqb9l3pp3l!r>@XV!YU@D8Nwy&6dM2u+*(fVZ`lgX$_ zt#b0Wj2kNXyRO8-l+i2)wlX;Dj58N`heuBJ#|O;{+EX!Vq`C66SP};8Wj9pegZ^f& zWRY?MAT0TzyC|XI%?JzD&AB}Mhiup>nWu8+@xYdg>Z8iQixqTD&G?m?BOMgr6XY3eR@-${9DO@#`{k#s`UGMdmx=B&bR9is>Oh9ncx^VR zsmoY1l-c&qeDz9qM>Fa*OP}w=B2e%Xcd%t-3M27!(y;cL(+pyjX@AnXJDOW zeHSe#`!b??gY>BZgl+@$K z4>M&izVq2$D#GK$P}0)8e11(Su^CK8KWwHH?N5P3JEJCq%lIB;BD+Hf)0&ACbpCnu zIZvC4Usk6owsdi7>WX&4S;FjZ3(?yfGObx2j66&-u5WqhLBm}C4)b`tAUkQ@d*cgt|| zZrJkE_mb}C89YYtY`;%D%uT!Ny~R=+A>Y>);+1^40RjmJ-6JJRe8M zOtmX-nc$OBo0Hn4pslfX=cX?X8*=x%r9H;bC3;n{pMIWP|7+r8nH{iF9A0?q2NH$g z`x}*%%*b0XW`G~S8Q=+UxmI_8E8x*}=Y-(-X9$X91~D4DdpUC>W#y!06%dlL^74|B z*AO-Sdx!#Z%~g$SOASZ{xE95K60(1h7V;m`s#HiY)^K<37Dp#H*=MFNK56D7{Fk&5 zSP+0ZsE)mVo|Locuwi}D=)CRFvHH`D)NzwxOu2!?@xiSC%I#BCK9kIenokVa$ci_H zhV}asziaZ~u!(aFmXDB3xhvI~r~S(bLkyo`od3bZrKc~Yd8={Uq3s17dfnKi zx&4G~03;+)0_QS#^qXuXP1)|1)v2l~^_(X?un$D%n}NLLK$u5*EkI7ob#A9og&;Ua z7;G%Yf?^&v4{I{h`=hr>iG<-a5#73u3?soWf}_vECBqAd+9`AC^RMV5hEgD-NaZ2# zLr-NZ_y>!a?MjbAnixX{c2WtRhfDG#5TMM|0n|D4tZc1h`8erO`7jSmuR(WO#%0egA zC|;Ib4cwY5-h>t}#uLjNLRFzAzX$S>pL<|l?MId9D2(gSlLniy{>m>}{4yY_$VcF9 zO6|^*n)sobkn+!IH>It0fCqQ$_hHtvTpzLIj5Id)7P@gtWrjgi=Av@FRZ$EHYRTTt z(?zmv>u3?&d`w*$0sMMij_I3slvR)>9s5zk=+WT)1~gubDWQenf1wpfa2BS=3>7=jyQ!hNnvpR>O%66Eg}xa