From a919844461d63a26fa6c1d8c7daa447cd5ef912e Mon Sep 17 00:00:00 2001 From: Koji Kawamura Date: Sun, 14 Aug 2016 22:01:52 +0900 Subject: [PATCH] NIFI-2567: Site-to-Site to send large data via HTTPS - It couldn't send data larger than about 7KB due to the mis-use of httpasyncclient library - Updated httpasyncclient from 4.1.1 to 4.1.2 - Let httpasyncclient framework to call produceContent multiple times as it gets ready to send more data via SSL session - Added HTTPS test cases to TestHttpClient, which failed without this fix --- nifi-commons/nifi-site-to-site-client/pom.xml | 2 +- .../remote/util/SiteToSiteRestApiClient.java | 65 ++++-- .../remote/client/http/TestHttpClient.java | 218 ++++++++++++++---- .../src/test/resources/certs/localhost-ks.jks | Bin 0 -> 3512 bytes .../src/test/resources/certs/localhost-ts.jks | Bin 0 -> 1816 bytes 5 files changed, 219 insertions(+), 66 deletions(-) create mode 100755 nifi-commons/nifi-site-to-site-client/src/test/resources/certs/localhost-ks.jks create mode 100755 nifi-commons/nifi-site-to-site-client/src/test/resources/certs/localhost-ts.jks diff --git a/nifi-commons/nifi-site-to-site-client/pom.xml b/nifi-commons/nifi-site-to-site-client/pom.xml index 63e5c125ee..c1857b52fa 100644 --- a/nifi-commons/nifi-site-to-site-client/pom.xml +++ b/nifi-commons/nifi-site-to-site-client/pom.xml @@ -55,7 +55,7 @@ org.apache.httpcomponents httpasyncclient - 4.1.1 + 4.1.2 diff --git a/nifi-commons/nifi-site-to-site-client/src/main/java/org/apache/nifi/remote/util/SiteToSiteRestApiClient.java b/nifi-commons/nifi-site-to-site-client/src/main/java/org/apache/nifi/remote/util/SiteToSiteRestApiClient.java index 8a379b7f0e..d228378b94 100644 --- a/nifi-commons/nifi-site-to-site-client/src/main/java/org/apache/nifi/remote/util/SiteToSiteRestApiClient.java +++ b/nifi-commons/nifi-site-to-site-client/src/main/java/org/apache/nifi/remote/util/SiteToSiteRestApiClient.java @@ -112,6 +112,7 @@ import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.regex.Pattern; import static org.apache.commons.lang3.StringUtils.isEmpty; @@ -463,6 +464,8 @@ public class SiteToSiteRestApiClient implements Closeable { final HttpAsyncRequestProducer asyncRequestProducer = new HttpAsyncRequestProducer() { private final ByteBuffer buffer = ByteBuffer.allocate(DATA_PACKET_CHANNEL_READ_BUFFER_SIZE); + private int totalRead = 0; + private int totalProduced = 0; @Override public HttpHost getTarget() { @@ -485,43 +488,59 @@ public class SiteToSiteRestApiClient implements Closeable { return post; } + private final AtomicBoolean bufferHasRemainingData = new AtomicBoolean(false); + @Override public void produceContent(final ContentEncoder encoder, final IOControl ioControl) throws IOException { - int totalRead = 0; - int totalProduced = 0; + if (bufferHasRemainingData.get()) { + // If there's remaining buffer last time, send it first. + writeBuffer(encoder); + if (bufferHasRemainingData.get()) { + return; + } + } + int read; // This read() blocks until data becomes available, // or corresponding outputStream is closed. - while ((read = dataPacketChannel.read(buffer)) > -1) { + if ((read = dataPacketChannel.read(buffer)) > -1) { - buffer.flip(); - while (buffer.hasRemaining()) { - totalProduced += encoder.write(buffer); - } - buffer.clear(); logger.trace("Read {} bytes from dataPacketChannel. {}", read, flowFilesPath); totalRead += read; + buffer.flip(); + writeBuffer(encoder); + + } else { + + final long totalWritten = commSession.getOutput().getBytesWritten(); + logger.debug("sending data to {} has reached to its end. produced {} bytes by reading {} bytes from channel. {} bytes written in this transaction.", + flowFilesPath, totalProduced, totalRead, totalWritten); + if (totalRead != totalWritten || totalProduced != totalWritten) { + final String msg = "Sending data to %s has reached to its end, but produced : read : wrote byte sizes (%d : %d : %d) were not equal. Something went wrong."; + throw new RuntimeException(String.format(msg, flowFilesPath, totalProduced, totalRead, totalWritten)); + } + transferDataLatch.countDown(); + encoder.complete(); + dataPacketChannel.close(); } - // There might be remaining bytes in buffer. Make sure it's fully drained. - buffer.flip(); + } + + private void writeBuffer(ContentEncoder encoder) throws IOException { while (buffer.hasRemaining()) { - totalProduced += encoder.write(buffer); + final int written = encoder.write(buffer); + logger.trace("written {} bytes to encoder.", written); + if (written == 0) { + logger.trace("Buffer still has remaining. {}", buffer); + bufferHasRemainingData.set(true); + return; + } + totalProduced += written; } - - final long totalWritten = commSession.getOutput().getBytesWritten(); - logger.debug("sending data to {} has reached to its end. produced {} bytes by reading {} bytes from channel. {} bytes written in this transaction.", - flowFilesPath, totalProduced, totalRead, totalWritten); - if (totalRead != totalWritten || totalProduced != totalWritten) { - final String msg = "Sending data to %s has reached to its end, but produced : read : wrote byte sizes (%d : $d : %d) were not equal. Something went wrong."; - throw new RuntimeException(String.format(msg, flowFilesPath, totalProduced, totalRead, totalWritten)); - } - transferDataLatch.countDown(); - encoder.complete(); - dataPacketChannel.close(); - + bufferHasRemainingData.set(false); + buffer.clear(); } @Override diff --git a/nifi-commons/nifi-site-to-site-client/src/test/java/org/apache/nifi/remote/client/http/TestHttpClient.java b/nifi-commons/nifi-site-to-site-client/src/test/java/org/apache/nifi/remote/client/http/TestHttpClient.java index 3f6dd89208..3a1b1bde05 100644 --- a/nifi-commons/nifi-site-to-site-client/src/test/java/org/apache/nifi/remote/client/http/TestHttpClient.java +++ b/nifi-commons/nifi-site-to-site-client/src/test/java/org/apache/nifi/remote/client/http/TestHttpClient.java @@ -20,6 +20,7 @@ import org.apache.nifi.controller.ScheduledState; import org.apache.nifi.remote.Peer; import org.apache.nifi.remote.Transaction; import org.apache.nifi.remote.TransferDirection; +import org.apache.nifi.remote.client.KeystoreType; import org.apache.nifi.remote.client.SiteToSiteClient; import org.apache.nifi.remote.codec.StandardFlowFileCodec; import org.apache.nifi.remote.io.CompressionInputStream; @@ -39,9 +40,16 @@ import org.apache.nifi.web.api.entity.ControllerEntity; import org.apache.nifi.web.api.entity.PeersEntity; import org.apache.nifi.web.api.entity.TransactionResultEntity; import org.codehaus.jackson.map.ObjectMapper; +import org.eclipse.jetty.server.Connector; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.SecureRequestCustomizer; import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.SslConnectionFactory; import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.servlet.ServletHandler; +import org.eclipse.jetty.util.ssl.SslContextFactory; import org.junit.After; import org.junit.AfterClass; import org.junit.Before; @@ -83,11 +91,14 @@ public class TestHttpClient { private static Logger logger = LoggerFactory.getLogger(TestHttpClient.class); private static Server server; + private static ServerConnector httpConnector; + private static ServerConnector sslConnector; final private static AtomicBoolean isTestCaseFinished = new AtomicBoolean(false); private static Set inputPorts; private static Set outputPorts; private static Set peers; + private static Set peersSecure; private static String serverChecksum; public static class SiteInfoServlet extends HttpServlet { @@ -96,11 +107,18 @@ public class TestHttpClient { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { final ControllerDTO controller = new ControllerDTO(); - controller.setRemoteSiteHttpListeningPort(server.getURI().getPort()); + + if (req.getLocalPort() == httpConnector.getLocalPort()) { + controller.setRemoteSiteHttpListeningPort(httpConnector.getLocalPort()); + controller.setSiteToSiteSecure(false); + } else { + controller.setRemoteSiteHttpListeningPort(sslConnector.getLocalPort()); + controller.setSiteToSiteSecure(true); + } + controller.setId("remote-controller-id"); controller.setInstanceId("remote-instance-id"); controller.setName("Remote NiFi Flow"); - controller.setSiteToSiteSecure(false); assertNotNull("Test case should set depending on the test scenario.", inputPorts); controller.setInputPorts(inputPorts); @@ -124,8 +142,13 @@ public class TestHttpClient { final PeersEntity peersEntity = new PeersEntity(); - assertNotNull("Test case should set depending on the test scenario.", peers); - peersEntity.setPeers(peers); + if (req.getLocalPort() == httpConnector.getLocalPort()) { + assertNotNull("Test case should set depending on the test scenario.", peers); + peersEntity.setPeers(peers); + } else { + assertNotNull("Test case should set depending on the test scenario.", peersSecure); + peersEntity.setPeers(peersSecure); + } respondWithJson(resp, peersEntity); } @@ -383,6 +406,21 @@ public class TestHttpClient { ServletHandler servletHandler = new ServletHandler(); contextHandler.insertHandler(servletHandler); + SslContextFactory sslContextFactory = new SslContextFactory(); + sslContextFactory.setKeyStorePath("src/test/resources/certs/localhost-ks.jks"); + sslContextFactory.setKeyStorePassword("localtest"); + sslContextFactory.setKeyStoreType("JKS"); + + httpConnector = new ServerConnector(server); + + HttpConfiguration https = new HttpConfiguration(); + https.addCustomizer(new SecureRequestCustomizer()); + sslConnector = new ServerConnector(server, + new SslConnectionFactory(sslContextFactory, "http/1.1"), + new HttpConnectionFactory(https)); + + server.setConnectors(new Connector[] { httpConnector, sslConnector }); + servletHandler.addServletWithMapping(SiteInfoServlet.class, "/site-to-site"); servletHandler.addServletWithMapping(PeersServlet.class, "/site-to-site/peers"); @@ -412,8 +450,7 @@ public class TestHttpClient { server.start(); - int serverPort = server.getURI().getPort(); - logger.info("Starting server on port {}", serverPort); + logger.info("Starting server on port {} for HTTP, and {} for HTTPS", httpConnector.getLocalPort(), sslConnector.getLocalPort()); } @AfterClass @@ -450,17 +487,26 @@ public class TestHttpClient { System.setProperty("org.slf4j.simpleLogger.log.org.apache.nifi.remote.protocol.http.HttpClientTransaction", "DEBUG"); final URI uri = server.getURI(); + isTestCaseFinished.set(false); + final PeerDTO peer = new PeerDTO(); - peer.setHostname(uri.getHost()); - peer.setPort(uri.getPort()); + peer.setHostname("localhost"); + peer.setPort(httpConnector.getLocalPort()); peer.setFlowFileCount(10); peer.setSecure(false); - isTestCaseFinished.set(false); - peers = new HashSet<>(); peers.add(peer); + final PeerDTO peerSecure = new PeerDTO(); + peerSecure.setHostname("localhost"); + peerSecure.setPort(sslConnector.getLocalPort()); + peerSecure.setFlowFileCount(10); + peerSecure.setSecure(true); + + peersSecure = new HashSet<>(); + peersSecure.add(peerSecure); + inputPorts = new HashSet<>(); final PortDTO runningInputPort = new PortDTO(); @@ -522,9 +568,20 @@ public class TestHttpClient { } private SiteToSiteClient.Builder getDefaultBuilder() { - final URI uri = server.getURI(); return new SiteToSiteClient.Builder().transportProtocol(SiteToSiteTransportProtocol.HTTP) - .url("http://" + uri.getHost() + ":" + uri.getPort() + "/nifi") + .url("http://localhost:" + httpConnector.getLocalPort() + "/nifi") + ; + } + + private SiteToSiteClient.Builder getDefaultBuilderHTTPS() { + return new SiteToSiteClient.Builder().transportProtocol(SiteToSiteTransportProtocol.HTTP) + .url("https://localhost:" + sslConnector.getLocalPort() + "/nifi") + .keystoreFilename("src/test/resources/certs/localhost-ks.jks") + .keystorePass("localtest") + .keystoreType(KeystoreType.JKS) + .truststoreFilename("src/test/resources/certs/localhost-ts.jks") + .truststorePass("localtest") + .truststoreType(KeystoreType.JKS) ; } @@ -594,9 +651,6 @@ public class TestHttpClient { @Test public void testSendSuccess() throws Exception { - final URI uri = server.getURI(); - - logger.info("uri={}", uri); try ( SiteToSiteClient client = getDefaultBuilder() .portName("input-running") @@ -627,12 +681,95 @@ public class TestHttpClient { } + @Test + public void testSendSuccessHTTPS() throws Exception { + + try ( + SiteToSiteClient client = getDefaultBuilderHTTPS() + .portName("input-running") + .build() + ) { + final Transaction transaction = client.createTransaction(TransferDirection.SEND); + + assertNotNull(transaction); + + serverChecksum = "1071206772"; + + + for (int i = 0; i < 20; i++) { + DataPacket packet = new DataPacketBuilder() + .contents("Example contents from client.") + .attr("Client attr 1", "Client attr 1 value") + .attr("Client attr 2", "Client attr 2 value") + .build(); + transaction.send(packet); + long written = ((Peer)transaction.getCommunicant()).getCommunicationsSession().getBytesWritten(); + logger.info("{}: {} bytes have been written.", i, written); + } + + transaction.confirm(); + + transaction.complete(); + } + + } + + private static void testSendLargeFile(SiteToSiteClient client) throws IOException { + final Transaction transaction = client.createTransaction(TransferDirection.SEND); + + assertNotNull(transaction); + + serverChecksum = "1527414060"; + + final int contentSize = 10_000; + final StringBuilder sb = new StringBuilder(contentSize); + for (int i = 0; i < contentSize; i++) { + sb.append("a"); + } + + DataPacket packet = new DataPacketBuilder() + .contents(sb.toString()) + .attr("Client attr 1", "Client attr 1 value") + .attr("Client attr 2", "Client attr 2 value") + .build(); + transaction.send(packet); + long written = ((Peer)transaction.getCommunicant()).getCommunicationsSession().getBytesWritten(); + logger.info("{} bytes have been written.", written); + + transaction.confirm(); + + transaction.complete(); + } + + @Test + public void testSendLargeFileHTTP() throws Exception { + + try ( + SiteToSiteClient client = getDefaultBuilder() + .portName("input-running") + .build() + ) { + testSendLargeFile(client); + } + + } + + @Test + public void testSendLargeFileHTTPS() throws Exception { + + try ( + SiteToSiteClient client = getDefaultBuilderHTTPS() + .portName("input-running") + .build() + ) { + testSendLargeFile(client); + } + + } + @Test public void testSendSuccessCompressed() throws Exception { - final URI uri = server.getURI(); - - logger.info("uri={}", uri); try ( SiteToSiteClient client = getDefaultBuilder() .portName("input-running") @@ -667,9 +804,6 @@ public class TestHttpClient { @Test public void testSendSlowClientSuccess() throws Exception { - final URI uri = server.getURI(); - - logger.info("uri={}", uri); try ( SiteToSiteClient client = getDefaultBuilder() .idleExpiration(1000, TimeUnit.MILLISECONDS) @@ -722,9 +856,6 @@ public class TestHttpClient { @Test public void testSendTimeout() throws Exception { - final URI uri = server.getURI(); - - logger.info("uri={}", uri); try ( SiteToSiteClient client = getDefaultBuilder() .timeout(1, TimeUnit.SECONDS) @@ -761,9 +892,6 @@ public class TestHttpClient { System.setProperty("org.slf4j.simpleLogger.log.org.apache.nifi.remote.protocol.http.HttpClientTransaction", "INFO"); - final URI uri = server.getURI(); - - logger.info("uri={}", uri); try ( SiteToSiteClient client = getDefaultBuilder() .idleExpiration(500, TimeUnit.MILLISECONDS) @@ -822,9 +950,6 @@ public class TestHttpClient { @Test public void testReceiveSuccess() throws Exception { - final URI uri = server.getURI(); - - logger.info("uri={}", uri); try ( SiteToSiteClient client = getDefaultBuilder() .portName("output-running") @@ -843,12 +968,30 @@ public class TestHttpClient { } } + @Test + public void testReceiveSuccessHTTPS() throws Exception { + + try ( + SiteToSiteClient client = getDefaultBuilderHTTPS() + .portName("output-running") + .build() + ) { + final Transaction transaction = client.createTransaction(TransferDirection.RECEIVE); + + assertNotNull(transaction); + + DataPacket packet; + while ((packet = transaction.receive()) != null) { + consumeDataPacket(packet); + } + transaction.confirm(); + transaction.complete(); + } + } + @Test public void testReceiveSuccessCompressed() throws Exception { - final URI uri = server.getURI(); - - logger.info("uri={}", uri); try ( SiteToSiteClient client = getDefaultBuilder() .portName("output-running") @@ -871,9 +1014,6 @@ public class TestHttpClient { @Test public void testReceiveSlowClientSuccess() throws Exception { - final URI uri = server.getURI(); - - logger.info("uri={}", uri); try ( SiteToSiteClient client = getDefaultBuilder() .portName("output-running") @@ -896,9 +1036,6 @@ public class TestHttpClient { @Test public void testReceiveTimeout() throws Exception { - final URI uri = server.getURI(); - - logger.info("uri={}", uri); try ( SiteToSiteClient client = getDefaultBuilder() .timeout(1, TimeUnit.SECONDS) @@ -918,9 +1055,6 @@ public class TestHttpClient { @Test public void testReceiveTimeoutAfterDataExchange() throws Exception { - final URI uri = server.getURI(); - - logger.info("uri={}", uri); try ( SiteToSiteClient client = getDefaultBuilder() .timeout(1, TimeUnit.SECONDS) diff --git a/nifi-commons/nifi-site-to-site-client/src/test/resources/certs/localhost-ks.jks b/nifi-commons/nifi-site-to-site-client/src/test/resources/certs/localhost-ks.jks new file mode 100755 index 0000000000000000000000000000000000000000..df36197d92ab8e9870f42666d74c47646fd56f26 GIT binary patch literal 3512 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