From 00346469a1e86150a3fa2511d10c627e3d3f016e Mon Sep 17 00:00:00 2001 From: Clint Wylie Date: Fri, 3 Apr 2020 23:29:23 -0700 Subject: [PATCH] Fix double count ssl connection metrics (#9594) * fix double counted jetty/numOpenConnections metric for ssl connections * tests * more better * style --- examples/bin/dsql-main | 2 +- .../docker/tls/generate-good-client-cert.sh | 2 +- .../generate-server-certs-and-keystores.sh | 2 +- .../jetty/JettyServerModule.java | 15 +- .../server/initialization/BaseJettyTest.java | 70 ++++++ .../server/initialization/JettyTest.java | 211 +++++++++++++++++- server/src/test/resources/server.jks | Bin 0 -> 1911 bytes server/src/test/resources/truststore.jks | Bin 0 -> 1641 bytes 8 files changed, 293 insertions(+), 9 deletions(-) create mode 100644 server/src/test/resources/server.jks create mode 100644 server/src/test/resources/truststore.jks diff --git a/examples/bin/dsql-main b/examples/bin/dsql-main index 8dfe8823a68c..cf685810bf3d 100755 --- a/examples/bin/dsql-main +++ b/examples/bin/dsql-main @@ -400,7 +400,7 @@ def main(): parser_fmt.add_argument('--format', type=str, default='table', choices=('csv', 'tsv', 'json', 'table'), help='Result format') parser_fmt.add_argument('--header', action='store_true', help='Include header row for formats "csv" and "tsv"') parser_fmt.add_argument('--tsv-delimiter', type=str, default='\t', help='Delimiter for format "tsv"') - parser_oth.add_argument('--context-option', '-c', type=str, action='append', help='Set context option for this connection, see https://docs.imply.io/on-prem/query-data/sql for options') + parser_oth.add_argument('--context-option', '-c', type=str, action='append', help='Set context option for this connection, see https://druid.apache.org/docs/latest/querying/sql.html#connection-context for options') parser_oth.add_argument('--execute', '-e', type=str, help='Execute single SQL query') args = parser.parse_args() diff --git a/integration-tests/docker/tls/generate-good-client-cert.sh b/integration-tests/docker/tls/generate-good-client-cert.sh index 895e6c34bad8..0f16c1449c5c 100755 --- a/integration-tests/docker/tls/generate-good-client-cert.sh +++ b/integration-tests/docker/tls/generate-good-client-cert.sh @@ -58,5 +58,5 @@ openssl x509 -req -days 3650 -in client.csr -CA root.pem -CAkey root.key -set_se openssl pkcs12 -export -in client.pem -inkey client.key -out client.p12 -name druid -CAfile root.pem -caname druid-it-root -password pass:druid123 keytool -importkeystore -srckeystore client.p12 -srcstoretype PKCS12 -destkeystore client.jks -deststoretype JKS -srcstorepass druid123 -deststorepass druid123 -# Create a Java truststore with the imply test cluster root CA +# Create a Java truststore with the druid test cluster root CA keytool -import -alias druid-it-root -keystore truststore.jks -file root.pem -storepass druid123 -noprompt diff --git a/integration-tests/docker/tls/generate-server-certs-and-keystores.sh b/integration-tests/docker/tls/generate-server-certs-and-keystores.sh index 8f38be303a8d..e26cdac40887 100755 --- a/integration-tests/docker/tls/generate-server-certs-and-keystores.sh +++ b/integration-tests/docker/tls/generate-server-certs-and-keystores.sh @@ -63,7 +63,7 @@ openssl x509 -req -days 3650 -in server.csr -CA root.pem -CAkey root.key -set_se openssl pkcs12 -export -in server.pem -inkey server.key -out server.p12 -name druid -CAfile root.pem -caname druid-it-root -password pass:druid123 keytool -importkeystore -srckeystore server.p12 -srcstoretype PKCS12 -destkeystore server.jks -deststoretype JKS -srcstorepass druid123 -deststorepass druid123 -# Create a Java truststore with the imply test cluster root CA +# Create a Java truststore with the druid test cluster root CA keytool -import -alias druid-it-root -keystore truststore.jks -file root.pem -storepass druid123 -noprompt # Revoke one of the client certs diff --git a/server/src/main/java/org/apache/druid/server/initialization/jetty/JettyServerModule.java b/server/src/main/java/org/apache/druid/server/initialization/jetty/JettyServerModule.java index bbb80e49aaf7..fc6f93ed6532 100644 --- a/server/src/main/java/org/apache/druid/server/initialization/jetty/JettyServerModule.java +++ b/server/src/main/java/org/apache/druid/server/initialization/jetty/JettyServerModule.java @@ -22,6 +22,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.jaxrs.json.JacksonJsonProvider; import com.fasterxml.jackson.jaxrs.smile.JacksonSmileProvider; +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.google.common.primitives.Ints; import com.google.inject.Binder; @@ -345,7 +346,13 @@ static Server makeAndInitializeServer( List monitoredConnFactories = new ArrayList<>(); for (ConnectionFactory cf : connector.getConnectionFactories()) { - monitoredConnFactories.add(new JettyMonitoringConnectionFactory(cf, ACTIVE_CONNECTIONS)); + // we only want to monitor the first connection factory, since it will pass the connection to subsequent + // connection factories (in this case HTTP/1.1 after the connection is unencrypted for SSL) + if (cf.getProtocol().equals(connector.getDefaultProtocol())) { + monitoredConnFactories.add(new JettyMonitoringConnectionFactory(cf, ACTIVE_CONNECTIONS)); + } else { + monitoredConnFactories.add(cf); + } } connector.setConnectionFactories(monitoredConnFactories); } @@ -531,4 +538,10 @@ protected TrustManager[] getTrustManagers( return newTrustManagers; } } + + @VisibleForTesting + public int getActiveConnections() + { + return ACTIVE_CONNECTIONS.get(); + } } diff --git a/server/src/test/java/org/apache/druid/server/initialization/BaseJettyTest.java b/server/src/test/java/org/apache/druid/server/initialization/BaseJettyTest.java index defe2de902a8..e38865a1a3ff 100644 --- a/server/src/test/java/org/apache/druid/server/initialization/BaseJettyTest.java +++ b/server/src/test/java/org/apache/druid/server/initialization/BaseJettyTest.java @@ -19,6 +19,7 @@ package org.apache.druid.server.initialization; +import com.google.inject.Inject; import com.google.inject.Injector; import com.google.inject.Key; import com.google.inject.servlet.GuiceFilter; @@ -60,6 +61,7 @@ import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import java.io.IOException; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.zip.Deflater; @@ -72,6 +74,7 @@ public abstract class BaseJettyTest protected HttpClient client; protected Server server; protected int port = -1; + protected int tlsPort = -1; public static void setProperties() { @@ -87,6 +90,8 @@ public void setup() throws Exception Injector injector = setupInjector(); final DruidNode node = injector.getInstance(Key.get(DruidNode.class, Self.class)); port = node.getPlaintextPort(); + tlsPort = node.getTlsPort(); + lifecycle = injector.getInstance(Lifecycle.class); lifecycle.start(); ClientHolder holder = injector.getInstance(ClientHolder.class); @@ -175,6 +180,71 @@ public Response hello() } } + @Path("/latched") + public static class LatchedResource + { + private final LatchedRequestStateHolder state; + + @Inject + public LatchedResource(LatchedRequestStateHolder state) + { + this.state = state; + } + + @GET + @Path("/hello") + @Produces(MediaType.APPLICATION_JSON) + public Response hello() + { + state.serverStartRequest(); + try { + state.serverWaitForClientReadyToFinishRequest(); + } + catch (InterruptedException ignored) { + } + return Response.ok(DEFAULT_RESPONSE_CONTENT).build(); + } + } + + public static class LatchedRequestStateHolder + { + private static final int TIMEOUT_MILLIS = 10_000; + + private CountDownLatch requestStartLatch; + private CountDownLatch requestEndLatch; + + public LatchedRequestStateHolder() + { + reset(); + } + + public void reset() + { + requestStartLatch = new CountDownLatch(1); + requestEndLatch = new CountDownLatch(1); + } + + public void clientWaitForServerToStartRequest() throws InterruptedException + { + requestStartLatch.await(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); + } + + public void serverStartRequest() + { + requestStartLatch.countDown(); + } + + public void serverWaitForClientReadyToFinishRequest() throws InterruptedException + { + requestEndLatch.await(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); + } + + public void clientReadyToFinishRequest() + { + requestEndLatch.countDown(); + } + } + @Path("/default") public static class DefaultResource { diff --git a/server/src/test/java/org/apache/druid/server/initialization/JettyTest.java b/server/src/test/java/org/apache/druid/server/initialization/JettyTest.java index 57608055ad4b..64ccd1487699 100644 --- a/server/src/test/java/org/apache/druid/server/initialization/JettyTest.java +++ b/server/src/test/java/org/apache/druid/server/initialization/JettyTest.java @@ -34,26 +34,35 @@ import org.apache.druid.guice.LifecycleModule; import org.apache.druid.guice.annotations.Self; import org.apache.druid.initialization.Initialization; +import org.apache.druid.java.util.http.client.HttpClient; +import org.apache.druid.java.util.http.client.HttpClientConfig; +import org.apache.druid.java.util.http.client.HttpClientInit; import org.apache.druid.java.util.http.client.Request; import org.apache.druid.java.util.http.client.response.InputStreamResponseHandler; import org.apache.druid.java.util.http.client.response.StatusResponseHandler; import org.apache.druid.java.util.http.client.response.StatusResponseHolder; +import org.apache.druid.metadata.PasswordProvider; import org.apache.druid.server.DruidNode; import org.apache.druid.server.initialization.jetty.JettyServerInitializer; +import org.apache.druid.server.initialization.jetty.JettyServerModule; import org.apache.druid.server.initialization.jetty.ServletFilterHolder; import org.apache.druid.server.security.AuthTestUtils; import org.apache.druid.server.security.AuthorizerMapper; import org.eclipse.jetty.server.Server; import org.jboss.netty.handler.codec.http.HttpMethod; +import org.joda.time.Duration; import org.junit.Assert; import org.junit.Ignore; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import javax.servlet.DispatcherType; import javax.servlet.Filter; import javax.servlet.http.HttpServletResponse; import javax.ws.rs.core.MediaType; import java.io.ByteArrayOutputStream; +import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.StringWriter; @@ -61,6 +70,8 @@ import java.net.URL; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.EnumSet; import java.util.Locale; import java.util.Map; @@ -74,10 +85,109 @@ public class JettyTest extends BaseJettyTest { + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + private HttpClientConfig sslConfig; + + private Injector injector; + + private LatchedRequestStateHolder latchedRequestState; + @Override protected Injector setupInjector() { - return Initialization.makeInjectorWithModules( + TLSServerConfig tlsConfig; + try { + File keyStore = new File(JettyTest.class.getClassLoader().getResource("server.jks").getFile()); + Path tmpKeyStore = Files.copy(keyStore.toPath(), new File(folder.newFolder(), "server.jks").toPath()); + File trustStore = new File(JettyTest.class.getClassLoader().getResource("truststore.jks").getFile()); + Path tmpTrustStore = Files.copy(trustStore.toPath(), new File(folder.newFolder(), "truststore.jks").toPath()); + PasswordProvider pp = () -> "druid123"; + tlsConfig = new TLSServerConfig() + { + @Override + public String getKeyStorePath() + { + return tmpKeyStore.toString(); + } + + @Override + public String getKeyStoreType() + { + return "jks"; + } + + @Override + public PasswordProvider getKeyStorePasswordProvider() + { + return pp; + } + + @Override + public PasswordProvider getKeyManagerPasswordProvider() + { + return pp; + } + + @Override + public String getTrustStorePath() + { + return tmpTrustStore.toString(); + } + + @Override + public String getTrustStoreAlgorithm() + { + return "PKIX"; + } + + @Override + public PasswordProvider getTrustStorePasswordProvider() + { + return pp; + } + + @Override + public String getCertAlias() + { + return "druid"; + } + + @Override + public boolean isRequireClientCertificate() + { + return false; + } + + @Override + public boolean isRequestClientCertificate() + { + return false; + } + + @Override + public boolean isValidateHostnames() + { + return false; + } + }; + + sslConfig = + HttpClientConfig.builder() + .withSslContext( + HttpClientInit.sslContextWithTrustedKeyStore(tmpTrustStore.toString(), pp.getPassword()) + ) + .withWorkerCount(1) + .withReadTimeout(Duration.ZERO) + .build(); + } + catch (IOException e) { + throw new RuntimeException(e); + } + + latchedRequestState = new LatchedRequestStateHolder(); + injector = Initialization.makeInjectorWithModules( GuiceInjectors.makeStartupInjector(), ImmutableList.of( new Module() @@ -88,9 +198,11 @@ public void configure(Binder binder) JsonConfigProvider.bindInstance( binder, Key.get(DruidNode.class, Self.class), - new DruidNode("test", "localhost", false, null, null, true, false) + new DruidNode("test", "localhost", false, 9988, 9999, true, true) ); + binder.bind(TLSServerConfig.class).toInstance(tlsConfig); binder.bind(JettyServerInitializer.class).to(JettyServerInit.class).in(LazySingleton.class); + binder.bind(LatchedRequestStateHolder.class).toInstance(latchedRequestState); Multibinder multibinder = Multibinder.newSetBinder( binder, @@ -132,7 +244,9 @@ public EnumSet getDispatcherType() } ); + Jerseys.addResource(binder, SlowResource.class); + Jerseys.addResource(binder, LatchedResource.class); Jerseys.addResource(binder, ExceptionResource.class); Jerseys.addResource(binder, DefaultResource.class); Jerseys.addResource(binder, DirectlyReturnResource.class); @@ -143,6 +257,7 @@ public EnumSet getDispatcherType() ) ); + return injector; } @Test @@ -209,13 +324,19 @@ public void testGzipResponseCompression() throws Exception final HttpURLConnection get = (HttpURLConnection) url.openConnection(); get.setRequestProperty("Accept-Encoding", "gzip"); Assert.assertEquals("gzip", get.getContentEncoding()); - Assert.assertEquals(DEFAULT_RESPONSE_CONTENT, IOUtils.toString(new GZIPInputStream(get.getInputStream()), StandardCharsets.UTF_8)); + Assert.assertEquals( + DEFAULT_RESPONSE_CONTENT, + IOUtils.toString(new GZIPInputStream(get.getInputStream()), StandardCharsets.UTF_8) + ); final HttpURLConnection post = (HttpURLConnection) url.openConnection(); post.setRequestProperty("Accept-Encoding", "gzip"); post.setRequestMethod("POST"); Assert.assertEquals("gzip", post.getContentEncoding()); - Assert.assertEquals(DEFAULT_RESPONSE_CONTENT, IOUtils.toString(new GZIPInputStream(post.getInputStream()), StandardCharsets.UTF_8)); + Assert.assertEquals( + DEFAULT_RESPONSE_CONTENT, + IOUtils.toString(new GZIPInputStream(post.getInputStream()), StandardCharsets.UTF_8) + ); final HttpURLConnection getNoGzip = (HttpURLConnection) url.openConnection(); Assert.assertNotEquals("gzip", getNoGzip.getContentEncoding()); @@ -224,7 +345,10 @@ public void testGzipResponseCompression() throws Exception final HttpURLConnection postNoGzip = (HttpURLConnection) url.openConnection(); postNoGzip.setRequestMethod("POST"); Assert.assertNotEquals("gzip", postNoGzip.getContentEncoding()); - Assert.assertEquals(DEFAULT_RESPONSE_CONTENT, IOUtils.toString(postNoGzip.getInputStream(), StandardCharsets.UTF_8)); + Assert.assertEquals( + DEFAULT_RESPONSE_CONTENT, + IOUtils.toString(postNoGzip.getInputStream(), StandardCharsets.UTF_8) + ); } // Tests that threads are not stuck when partial chunk is not finalized @@ -311,4 +435,81 @@ public void testGzipRequestDecompression() throws Exception new InputStreamResponseHandler() ).get()), Charset.defaultCharset())); } + + @Test + public void testNumConnectionsMetricHttp() throws Exception + { + String text = "hello"; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (GZIPOutputStream gzipOutputStream = new GZIPOutputStream(out)) { + gzipOutputStream.write(text.getBytes(Charset.defaultCharset())); + } + Request request = new Request(HttpMethod.GET, new URL("http://localhost:" + port + "/latched/hello")); + request.setHeader("Content-Encoding", "gzip"); + request.setContent(MediaType.TEXT_PLAIN, out.toByteArray()); + + JettyServerModule jsm = injector.getInstance(JettyServerModule.class); + latchedRequestState.reset(); + + Assert.assertEquals(0, jsm.getActiveConnections()); + ListenableFuture go = client.go( + request, + new InputStreamResponseHandler() + ); + latchedRequestState.clientWaitForServerToStartRequest(); + Assert.assertEquals(1, jsm.getActiveConnections()); + latchedRequestState.clientReadyToFinishRequest(); + go.get(); + waitForJettyServerModuleActiveConnectionsZero(jsm); + Assert.assertEquals(0, jsm.getActiveConnections()); + } + + @Test + public void testNumConnectionsMetricHttps() throws Exception + { + String text = "hello"; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (GZIPOutputStream gzipOutputStream = new GZIPOutputStream(out)) { + gzipOutputStream.write(text.getBytes(Charset.defaultCharset())); + } + Request request = new Request(HttpMethod.GET, new URL("https://localhost:" + tlsPort + "/latched/hello")); + request.setHeader("Content-Encoding", "gzip"); + request.setContent(MediaType.TEXT_PLAIN, out.toByteArray()); + HttpClient client; + try { + client = HttpClientInit.createClient( + sslConfig, + lifecycle + ); + } + catch (Exception e) { + throw new RuntimeException(e); + } + + JettyServerModule jsm = injector.getInstance(JettyServerModule.class); + latchedRequestState.reset(); + + Assert.assertEquals(0, jsm.getActiveConnections()); + ListenableFuture go = client.go( + request, + new InputStreamResponseHandler() + ); + latchedRequestState.clientWaitForServerToStartRequest(); + Assert.assertEquals(1, jsm.getActiveConnections()); + latchedRequestState.clientReadyToFinishRequest(); + go.get(); + waitForJettyServerModuleActiveConnectionsZero(jsm); + Assert.assertEquals(0, jsm.getActiveConnections()); + } + + private void waitForJettyServerModuleActiveConnectionsZero(JettyServerModule jsm) throws InterruptedException + { + // it can take a bit to close the connection, so maybe sleep for a while and hope it closes + final int sleepTimeMills = 10; + final int totalSleeps = 5_000 / sleepTimeMills; + int count = 0; + while (jsm.getActiveConnections() > 0 && count++ < totalSleeps) { + Thread.sleep(sleepTimeMills); + } + } } diff --git a/server/src/test/resources/server.jks b/server/src/test/resources/server.jks new file mode 100644 index 0000000000000000000000000000000000000000..664e3754cc467146e1977829f5b09e6aced833a6 GIT binary patch literal 1911 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 literal 0 HcmV?d00001