diff --git a/build.gradle b/build.gradle index 387b777..e80dee0 100644 --- a/build.gradle +++ b/build.gradle @@ -65,7 +65,8 @@ jar { test { jvmArgs "-javaagent:" + configurations.alpnagent.asPath - //include 'org/xbib/netty/http/client/test/Http2Test*' + include 'org/xbib/netty/http/client/test/Http2FrameAdapterTest*' + include 'org/xbib/netty/http/client/test/InboundHttp2ToHttpAdapterTest*' testLogging { showStandardStreams = false exceptionFormat = 'full' diff --git a/src/test/java/org/xbib/netty/http/client/test/Http2Test.java b/src/test/java/org/xbib/netty/http/client/test/Http2FrameAdapterTest.java similarity index 59% rename from src/test/java/org/xbib/netty/http/client/test/Http2Test.java rename to src/test/java/org/xbib/netty/http/client/test/Http2FrameAdapterTest.java index 26b65bc..fe911bd 100644 --- a/src/test/java/org/xbib/netty/http/client/test/Http2Test.java +++ b/src/test/java/org/xbib/netty/http/client/test/Http2FrameAdapterTest.java @@ -4,16 +4,13 @@ import io.netty.bootstrap.Bootstrap; import io.netty.buffer.ByteBuf; import io.netty.channel.Channel; import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.ChannelInboundHandlerAdapter; import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPromise; import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.codec.http.HttpMethod; -import io.netty.handler.codec.http2.DefaultHttp2Connection; import io.netty.handler.codec.http2.DefaultHttp2Headers; -import io.netty.handler.codec.http2.DelegatingDecompressorFrameListener; -import io.netty.handler.codec.http2.Http2Connection; import io.netty.handler.codec.http2.Http2ConnectionHandler; import io.netty.handler.codec.http2.Http2ConnectionHandlerBuilder; import io.netty.handler.codec.http2.Http2Exception; @@ -21,8 +18,8 @@ import io.netty.handler.codec.http2.Http2FrameAdapter; import io.netty.handler.codec.http2.Http2FrameLogger; import io.netty.handler.codec.http2.Http2SecurityUtil; import io.netty.handler.codec.http2.Http2Settings; -import io.netty.handler.codec.http2.InboundHttp2ToHttpAdapterBuilder; import io.netty.handler.logging.LogLevel; +import io.netty.handler.logging.LoggingHandler; import io.netty.handler.ssl.ApplicationProtocolConfig; import io.netty.handler.ssl.ApplicationProtocolNames; import io.netty.handler.ssl.SslContext; @@ -32,14 +29,11 @@ import io.netty.handler.ssl.SslProvider; import io.netty.handler.ssl.SupportedCipherSuiteFilter; import io.netty.handler.ssl.util.InsecureTrustManagerFactory; import org.junit.Test; -import org.xbib.netty.http.client.Http2Handler; -import org.xbib.netty.http.client.TrafficLoggingHandler; import javax.net.ssl.SNIHostName; import javax.net.ssl.SNIServerName; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLParameters; -import java.io.InputStream; import java.net.InetSocketAddress; import java.util.Arrays; import java.util.concurrent.CountDownLatch; @@ -52,7 +46,7 @@ import java.util.logging.SimpleFormatter; /** */ -public class Http2Test { +public class Http2FrameAdapterTest { static { System.setProperty("java.util.logging.SimpleFormatter.format", @@ -69,13 +63,11 @@ public class Http2Test { } } - private static final Logger logger = Logger.getLogger(""); - private final int serverExpectedDataFrames = 1; - - - public void testGeneric() throws Exception { + @Test + public void testHttp2FrameAdapter() throws Exception { + final int serverExpectedDataFrames = 1; final InetSocketAddress inetSocketAddress = new InetSocketAddress("http2-push.io", 443); final CountDownLatch dataLatch = new CountDownLatch(serverExpectedDataFrames); EventLoopGroup group = new NioEventLoopGroup(); @@ -91,14 +83,12 @@ public class Http2Test { SslContext sslContext = SslContextBuilder.forClient() .sslProvider(SslProvider.OPENSSL) .trustManager(InsecureTrustManagerFactory.INSTANCE) - .keyManager((InputStream) null, null, null) .ciphers(Http2SecurityUtil.CIPHERS, SupportedCipherSuiteFilter.INSTANCE) .applicationProtocolConfig(new ApplicationProtocolConfig( ApplicationProtocolConfig.Protocol.ALPN, ApplicationProtocolConfig.SelectorFailureBehavior.NO_ADVERTISE, ApplicationProtocolConfig.SelectedListenerFailureBehavior.ACCEPT, - ApplicationProtocolNames.HTTP_2, - ApplicationProtocolNames.HTTP_1_1)) + ApplicationProtocolNames.HTTP_2)) .build(); SslHandler sslHandler = sslContext.newHandler(ch.alloc()); SSLEngine engine = sslHandler.engine(); @@ -113,6 +103,7 @@ public class Http2Test { @Override public void onSettingsRead(ChannelHandlerContext ctx, Http2Settings settings) throws Http2Exception { + logger.log(Level.FINE, "settings received, now writing headers"); Http2ConnectionHandler handler = ctx.pipeline().get(Http2ConnectionHandler.class); handler.encoder().writeHeaders(ctx, 3, new DefaultHttp2Headers().method(HttpMethod.GET.asciiName()) @@ -135,7 +126,9 @@ public class Http2Test { } }); clientChannel = bs.connect(inetSocketAddress).syncUninterruptibly().channel(); + logger.log(Level.INFO, () -> "waiting for HTTP/2 data"); dataLatch.await(); + logger.log(Level.INFO, () -> "done, data arrived"); } finally { if (clientChannel != null) { clientChannel.close(); @@ -144,66 +137,34 @@ public class Http2Test { } } + class TrafficLoggingHandler extends LoggingHandler { - public void testHttpAdapter() throws Exception { - final InetSocketAddress inetSocketAddress = new InetSocketAddress("http2-push.io", 443); - final CountDownLatch dataLatch = new CountDownLatch(serverExpectedDataFrames); - EventLoopGroup group = new NioEventLoopGroup(); - Channel clientChannel = null; - try { - Bootstrap bs = new Bootstrap(); - bs.group(group); - bs.channel(NioSocketChannel.class); - bs.handler(new ChannelInitializer() { - @Override - protected void initChannel(Channel ch) throws Exception { - ch.pipeline().addLast(new TrafficLoggingHandler()); - SslContext sslContext = SslContextBuilder.forClient() - .sslProvider(SslProvider.OPENSSL) - .trustManager(InsecureTrustManagerFactory.INSTANCE) - .keyManager((InputStream) null, null, null) - .ciphers(Http2SecurityUtil.CIPHERS, SupportedCipherSuiteFilter.INSTANCE) - .applicationProtocolConfig(new ApplicationProtocolConfig( - ApplicationProtocolConfig.Protocol.ALPN, - ApplicationProtocolConfig.SelectorFailureBehavior.NO_ADVERTISE, - ApplicationProtocolConfig.SelectedListenerFailureBehavior.ACCEPT, - ApplicationProtocolNames.HTTP_2, - ApplicationProtocolNames.HTTP_1_1)) - .build(); - SslHandler sslHandler = sslContext.newHandler(ch.alloc()); - SSLEngine engine = sslHandler.engine(); - String fullQualifiedHostname = inetSocketAddress.getHostName(); - SSLParameters params = engine.getSSLParameters(); - params.setServerNames(Arrays.asList(new SNIServerName[]{new SNIHostName(fullQualifiedHostname)})); - engine.setSSLParameters(params); - ch.pipeline().addLast(sslHandler); - // settings handler - final Http2Connection http2Connection = new DefaultHttp2Connection(false); - Http2ConnectionHandler http2ConnectionHandler = new Http2ConnectionHandlerBuilder() - .frameLogger(new Http2FrameLogger(LogLevel.INFO, "client")) - .frameListener(new DelegatingDecompressorFrameListener(http2Connection, - new InboundHttp2ToHttpAdapterBuilder(http2Connection) - .maxContentLength(1024 * 1024) - .propagateSettings(true) - .validateHttpHeaders(false) - .build())) - .build(); - ch.pipeline().addLast(http2ConnectionHandler); - ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { - @Override - public void channelInactive(ChannelHandlerContext ctx) throws Exception { - dataLatch.countDown(); - } - }); - } - }); - clientChannel = bs.connect(inetSocketAddress).syncUninterruptibly().channel(); - dataLatch.await(); - } finally { - if (clientChannel != null) { - clientChannel.close(); + TrafficLoggingHandler() { + super("client", LogLevel.TRACE); + } + + @Override + public void channelRegistered(ChannelHandlerContext ctx) throws Exception { + ctx.fireChannelRegistered(); + } + + @Override + public void channelUnregistered(ChannelHandlerContext ctx) throws Exception { + ctx.fireChannelUnregistered(); + } + + @Override + public void flush(ChannelHandlerContext ctx) throws Exception { + ctx.flush(); + } + + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { + if (msg instanceof ByteBuf && !((ByteBuf) msg).isReadable()) { + ctx.write(msg, promise); + } else { + super.write(ctx, msg, promise); } - group.shutdownGracefully(); } } } diff --git a/src/test/java/org/xbib/netty/http/client/test/InboundHttp2ToHttpAdapterTest.java b/src/test/java/org/xbib/netty/http/client/test/InboundHttp2ToHttpAdapterTest.java new file mode 100644 index 0000000..20cbcb2 --- /dev/null +++ b/src/test/java/org/xbib/netty/http/client/test/InboundHttp2ToHttpAdapterTest.java @@ -0,0 +1,306 @@ +package org.xbib.netty.http.client.test; + +import io.netty.bootstrap.Bootstrap; +import io.netty.buffer.ByteBuf; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPromise; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.ChannelInputShutdownReadComplete; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.handler.codec.http.DefaultFullHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http2.DefaultHttp2Connection; +import io.netty.handler.codec.http2.DelegatingDecompressorFrameListener; +import io.netty.handler.codec.http2.Http2Connection; +import io.netty.handler.codec.http2.Http2ConnectionPrefaceWrittenEvent; +import io.netty.handler.codec.http2.Http2FrameLogger; +import io.netty.handler.codec.http2.Http2SecurityUtil; +import io.netty.handler.codec.http2.Http2Settings; +import io.netty.handler.codec.http2.HttpConversionUtil; +import io.netty.handler.codec.http2.HttpToHttp2ConnectionHandler; +import io.netty.handler.codec.http2.HttpToHttp2ConnectionHandlerBuilder; +import io.netty.handler.codec.http2.InboundHttp2ToHttpAdapterBuilder; +import io.netty.handler.logging.LogLevel; +import io.netty.handler.logging.LoggingHandler; +import io.netty.handler.ssl.ApplicationProtocolConfig; +import io.netty.handler.ssl.ApplicationProtocolNames; +import io.netty.handler.ssl.ApplicationProtocolNegotiationHandler; +import io.netty.handler.ssl.SslCloseCompletionEvent; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.SslHandler; +import io.netty.handler.ssl.SslProvider; +import io.netty.handler.ssl.SupportedCipherSuiteFilter; +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; +import io.netty.util.internal.PlatformDependent; +import org.junit.Test; + +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SNIServerName; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLParameters; +import java.net.InetSocketAddress; +import java.net.URL; +import java.util.AbstractMap; +import java.util.Arrays; +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.logging.ConsoleHandler; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.Logger; +import java.util.logging.SimpleFormatter; + +/** + */ +public class InboundHttp2ToHttpAdapterTest { + + static { + System.setProperty("java.util.logging.SimpleFormatter.format", + "%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS.%1$tL %4$-7s [%3$s] %2$s %5$s %6$s%n"); + LogManager.getLogManager().reset(); + Logger rootLogger = LogManager.getLogManager().getLogger(""); + Handler handler = new ConsoleHandler(); + handler.setFormatter(new SimpleFormatter()); + rootLogger.addHandler(handler); + rootLogger.setLevel(Level.ALL); + for (Handler h : rootLogger.getHandlers()) { + handler.setFormatter(new SimpleFormatter()); + h.setLevel(Level.ALL); + } + } + + private static final Logger logger = Logger.getLogger(""); + + @Test + public void testInboundHttp2ToHttpAdapter() throws Exception { + URL url = new URL("https://http2-push.io"); + final InetSocketAddress inetSocketAddress = new InetSocketAddress(url.getHost(), 443); + EventLoopGroup group = new NioEventLoopGroup(); + Channel clientChannel = null; + SettingsHandler settingsHandler = new SettingsHandler(); + ResponseHandler responseHandler = new ResponseHandler(); + try { + Bootstrap bs = new Bootstrap(); + bs.group(group); + bs.channel(NioSocketChannel.class); + bs.handler(new ChannelInitializer() { + @Override + protected void initChannel(Channel ch) throws Exception { + ch.pipeline().addLast(new TrafficLoggingHandler()); + SslContext sslContext = SslContextBuilder.forClient() + .sslProvider(SslProvider.OPENSSL) + .trustManager(InsecureTrustManagerFactory.INSTANCE) + .ciphers(Http2SecurityUtil.CIPHERS, SupportedCipherSuiteFilter.INSTANCE) + .applicationProtocolConfig(new ApplicationProtocolConfig( + ApplicationProtocolConfig.Protocol.ALPN, + ApplicationProtocolConfig.SelectorFailureBehavior.NO_ADVERTISE, + ApplicationProtocolConfig.SelectedListenerFailureBehavior.ACCEPT, + ApplicationProtocolNames.HTTP_2)) + .build(); + SslHandler sslHandler = sslContext.newHandler(ch.alloc()); + SSLEngine engine = sslHandler.engine(); + String fullQualifiedHostname = inetSocketAddress.getHostName(); + SSLParameters params = engine.getSSLParameters(); + params.setServerNames(Arrays.asList(new SNIServerName[]{new SNIHostName(fullQualifiedHostname)})); + engine.setSSLParameters(params); + ch.pipeline().addLast(sslHandler); + ch.pipeline().addLast(new Http2NegotiationHandler(settingsHandler, responseHandler)); + } + }); + clientChannel = bs.connect(inetSocketAddress).syncUninterruptibly().channel(); + settingsHandler.awaitSettings(clientChannel.newPromise()); + HttpRequest httpRequest = new DefaultFullHttpRequest(HttpVersion.valueOf("HTTP/2.0"), + HttpMethod.GET, url.toExternalForm()); + logger.log(Level.FINE, "HTTP2: sending request"); + responseHandler.put(3, clientChannel.write(httpRequest), clientChannel.newPromise()); + clientChannel.flush(); + logger.log(Level.FINE, "HTTP2: waiting for responses"); + responseHandler.awaitResponses(); + logger.log(Level.FINE, "HTTP2: done"); + } finally { + if (clientChannel != null) { + clientChannel.close(); + } + group.shutdownGracefully(); + } + } + + private HttpToHttp2ConnectionHandler createHttp2ConnectionHandler() { + final Http2Connection http2Connection = new DefaultHttp2Connection(false); + return new HttpToHttp2ConnectionHandlerBuilder() + .connection(http2Connection) + .frameLogger(new Http2FrameLogger(LogLevel.INFO, "client")) + .frameListener(new DelegatingDecompressorFrameListener(http2Connection, + new InboundHttp2ToHttpAdapterBuilder(http2Connection) + .maxContentLength(10 * 1024 * 1024) + .propagateSettings(true) + .validateHttpHeaders(false) + .build())) + .build(); + } + + class Http2NegotiationHandler extends ApplicationProtocolNegotiationHandler { + + private final SettingsHandler settingsHandler; + + private final ResponseHandler responseHandler; + + Http2NegotiationHandler(SettingsHandler settingsHandler, ResponseHandler responseHandler) { + super(""); + this.settingsHandler = settingsHandler; + this.responseHandler = responseHandler; + } + + @Override + protected void configurePipeline(ChannelHandlerContext ctx, String protocol) throws Exception { + if (ApplicationProtocolNames.HTTP_2.equals(protocol)) { + ctx.pipeline().addLast(createHttp2ConnectionHandler()); + ctx.pipeline().addLast(settingsHandler); + ctx.pipeline().addLast(new UserEventLogger()); + ctx.pipeline().addLast(responseHandler); + logger.log(Level.FINE, "negotiated HTTP/2: pipeline = " + ctx.pipeline().names()); + return; + } + ctx.close(); + throw new IllegalStateException("unexpected protocol: " + protocol); + } + } + + class SettingsHandler extends SimpleChannelInboundHandler { + + private ChannelPromise promise; + + @Override + protected void channelRead0(ChannelHandlerContext ctx, Http2Settings msg) throws Exception { + promise.setSuccess(); + ctx.pipeline().remove(this); + } + + void awaitSettings(ChannelPromise promise) throws Exception { + this.promise = promise; + int timeout = 5000; + if (!promise.awaitUninterruptibly(timeout, TimeUnit.MILLISECONDS)) { + throw new IllegalStateException("time out while waiting for HTTP/2 settings"); + } + if (!promise.isSuccess()) { + throw new RuntimeException(promise.cause()); + } + } + } + + class ResponseHandler extends SimpleChannelInboundHandler { + + private final Map> streamidPromiseMap; + + ResponseHandler() { + this.streamidPromiseMap = PlatformDependent.newConcurrentHashMap(); + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, FullHttpResponse httpResponse) throws Exception { + Integer streamId = httpResponse.headers().getInt(HttpConversionUtil.ExtensionHeaderNames.STREAM_ID.text()); + if (streamId == null) { + logger.log(Level.WARNING, () -> "stream ID missing"); + return; + } + Map.Entry entry = streamidPromiseMap.get(streamId); + if (entry != null) { + entry.getValue().setSuccess(); + } else { + logger.log(Level.WARNING, () -> "stream id not found in promise map: " + streamId); + } + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + logger.log(Level.FINE, () -> "exception caught " + cause.getMessage()); + } + + void put(int streamId, ChannelFuture channelFuture, ChannelPromise promise) { + logger.log(Level.FINE, () -> "put stream ID " + streamId); + streamidPromiseMap.put(streamId, new AbstractMap.SimpleEntry<>(channelFuture, promise)); + } + + void awaitResponses() { + int timeout = 5000; + Iterator>> iterator = streamidPromiseMap.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry> entry = iterator.next(); + ChannelFuture channelFuture = entry.getValue().getKey(); + if (!channelFuture.awaitUninterruptibly(timeout, TimeUnit.MILLISECONDS)) { + throw new IllegalStateException("time out while waiting to write for stream id " + entry.getKey()); + } + if (!channelFuture.isSuccess()) { + throw new RuntimeException(channelFuture.cause()); + } + ChannelPromise promise = entry.getValue().getValue(); + if (!promise.awaitUninterruptibly(timeout, TimeUnit.MILLISECONDS)) { + throw new IllegalStateException("time out while waiting for response on stream id " + entry.getKey()); + } + if (!promise.isSuccess()) { + throw new RuntimeException(promise.cause()); + } + iterator.remove(); + } + } + } + + class UserEventLogger extends ChannelInboundHandlerAdapter { + + @Override + public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { + logger.log(Level.FINE, () -> "got user event " + evt); + if (evt instanceof Http2ConnectionPrefaceWrittenEvent || + evt instanceof SslCloseCompletionEvent || + evt instanceof ChannelInputShutdownReadComplete) { + // Expected events + logger.log(Level.FINE, () -> "user event is expected: " + evt); + return; + } + super.userEventTriggered(ctx, evt); + } + } + + class TrafficLoggingHandler extends LoggingHandler { + + TrafficLoggingHandler() { + super("client", LogLevel.TRACE); + } + + @Override + public void channelRegistered(ChannelHandlerContext ctx) throws Exception { + ctx.fireChannelRegistered(); + } + + @Override + public void channelUnregistered(ChannelHandlerContext ctx) throws Exception { + ctx.fireChannelUnregistered(); + } + + @Override + public void flush(ChannelHandlerContext ctx) throws Exception { + ctx.flush(); + } + + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { + if (msg instanceof ByteBuf && !((ByteBuf) msg).isReadable()) { + ctx.write(msg, promise); + } else { + super.write(ctx, msg, promise); + } + } + } +}