diff --git a/http-client-core/src/main/java/io/micronaut/http/client/HttpClientConfiguration.java b/http-client-core/src/main/java/io/micronaut/http/client/HttpClientConfiguration.java index eba4b60c886..d4fafd1bb20 100644 --- a/http-client-core/src/main/java/io/micronaut/http/client/HttpClientConfiguration.java +++ b/http-client-core/src/main/java/io/micronaut/http/client/HttpClientConfiguration.java @@ -16,6 +16,7 @@ package io.micronaut.http.client; import io.micronaut.context.env.CachedEnvironment; +import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.NextMajorVersion; import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.Nullable; @@ -182,6 +183,8 @@ public abstract class HttpClientConfiguration { @Nullable private String addressResolverGroupName = null; + private String pcapLoggingPathPattern = null; + /** * Default constructor. */ @@ -872,6 +875,28 @@ public HttpClientConfiguration.Http2ClientConfiguration getHttp2Configuration() return null; } + /** + * The path pattern to use for logging outgoing connections to pcap. This is an unsupported option: Behavior may + * change, or it may disappear entirely, without notice! Only implemented for netty. + * + * @return The path pattern, or {@code null} if logging is disabled. + */ + @Internal + public String getPcapLoggingPathPattern() { + return pcapLoggingPathPattern; + } + + /** + * The path pattern to use for logging outgoing connections to pcap. This is an unsupported option: Behavior may + * change, or it may disappear entirely, without notice! Only implemented for netty. + * + * @param pcapLoggingPathPattern The path pattern, or {@code null} to disable logging. + */ + @Internal + public void setPcapLoggingPathPattern(String pcapLoggingPathPattern) { + this.pcapLoggingPathPattern = pcapLoggingPathPattern; + } + /** * Configuration for the HTTP client connnection pool. */ diff --git a/http-client/src/main/java/io/micronaut/http/client/netty/ConnectionManager.java b/http-client/src/main/java/io/micronaut/http/client/netty/ConnectionManager.java index 33899a5c317..132c5bf6f41 100644 --- a/http-client/src/main/java/io/micronaut/http/client/netty/ConnectionManager.java +++ b/http-client/src/main/java/io/micronaut/http/client/netty/ConnectionManager.java @@ -73,6 +73,7 @@ import io.netty.handler.codec.http2.Http2StreamFrameToHttpObjectCodec; import io.netty.handler.logging.LogLevel; import io.netty.handler.logging.LoggingHandler; +import io.netty.handler.pcap.PcapWriteHandler; import io.netty.handler.proxy.HttpProxyHandler; import io.netty.handler.proxy.Socks5ProxyHandler; import io.netty.handler.ssl.ApplicationProtocolNames; @@ -115,10 +116,13 @@ import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLException; import javax.net.ssl.SSLParameters; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; import java.net.InetSocketAddress; import java.net.Proxy; import java.net.SocketAddress; import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Base64; import java.util.List; @@ -128,6 +132,7 @@ import java.util.OptionalInt; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -665,6 +670,91 @@ private void addLogHandler(Channel ch) { }); } + private void insertPcapLoggingHandlerLazy(Channel ch, String qualifier) { + if (configuration.getPcapLoggingPathPattern() == null) { + return; + } + + if (ch.isActive()) { + ChannelHandler actual = createPcapLoggingHandler(ch, qualifier); + ch.pipeline().addLast("pcap-" + qualifier, actual); + } else { + ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { + @Override + public void channelActive(ChannelHandlerContext ctx) throws Exception { + ChannelHandler actual = createPcapLoggingHandler(ch, qualifier); + ctx.pipeline().addBefore(ctx.name(), "pcap-" + qualifier, actual); + ctx.pipeline().remove(ctx.name()); + + super.channelActive(ctx); + } + }); + } + } + + @Nullable + private ChannelHandler createPcapLoggingHandler(Channel ch, String qualifier) { + String pattern = configuration.getPcapLoggingPathPattern(); + if (pattern == null) { + return null; + } + + String path = pattern; + path = path.replace("{qualifier}", qualifier); + if (ch.localAddress() != null) { + path = path.replace("{localAddress}", resolveIfNecessary(ch.localAddress())); + } + if (ch.remoteAddress() != null) { + path = path.replace("{remoteAddress}", resolveIfNecessary(ch.remoteAddress())); + } + if (udpBootstrap != null && ch instanceof QuicStreamChannel qsc) { + path = path.replace("{localAddress}", resolveIfNecessary(qsc.parent().localSocketAddress())); + path = path.replace("{remoteAddress}", resolveIfNecessary(qsc.parent().remoteSocketAddress())); + } + path = path.replace("{random}", Long.toHexString(ThreadLocalRandom.current().nextLong())); + path = path.replace("{timestamp}", Instant.now().toString()); + + path = path.replace(':', '_'); // for windows + + log.warn("Logging *full* request data, as configured. This will contain sensitive information! Path: '{}'", path); + + try { + PcapWriteHandler.Builder builder = PcapWriteHandler.builder(); + + if (udpBootstrap != null && ch instanceof QuicStreamChannel qsc) { + builder.forceTcpChannel((InetSocketAddress) qsc.parent().localSocketAddress(), (InetSocketAddress) qsc.parent().remoteSocketAddress(), true); + } + + return builder.build(new FileOutputStream(path)); + } catch (FileNotFoundException e) { + log.warn("Failed to create target pcap at '{}', not logging.", path, e); + return null; + } + } + + /** + * Force resolution of the given address, and then transform it to string. This prevents any potential user data + * appearing in the file path + */ + private String resolveIfNecessary(SocketAddress address) { + if (address instanceof InetSocketAddress socketAddress) { + if (socketAddress.isUnresolved()) { + // try resolution + socketAddress = new InetSocketAddress(socketAddress.getHostString(), socketAddress.getPort()); + if (socketAddress.isUnresolved()) { + // resolution failed, bail + return "unresolved"; + } + } + return socketAddress.getAddress().getHostAddress() + ':' + socketAddress.getPort(); + } + String s = address.toString(); + if (s.contains("/")) { + return "weird"; + } + return s; + } + /** * Initializer for HTTP2 multiplexing, called either in h2c mode, or after ALPN in TLS. The * channel should already contain a {@link #makeFrameCodec() frame codec} that does the HTTP2 @@ -769,10 +859,15 @@ private final class AdaptiveAlpnChannelInitializer extends ChannelInitializer protected void initChannel(@NonNull Channel ch) throws Exception { NettyClientCustomizer connectionCustomizer = clientCustomizer.specializeForChannel(ch, NettyClientCustomizer.ChannelRole.CONNECTION); + insertPcapLoggingHandlerLazy(ch, "outer"); + Http2FrameCodec frameCodec = makeFrameCodec(); HttpClientCodec sourceCodec = new HttpClientCodec(); @@ -907,6 +1004,8 @@ public void bind(ChannelHandlerContext ctx, SocketAddress localAddress, ChannelP private void initChannel(Channel ch) { NettyClientCustomizer channelCustomizer = clientCustomizer.specializeForChannel(ch, NettyClientCustomizer.ChannelRole.CONNECTION); + insertPcapLoggingHandlerLazy(ch, "outer"); + ch.pipeline() .addLast(Http3.newQuicClientCodecBuilder() .sslEngineProvider(c -> ((QuicSslContext) http3SslContext).newEngine(c.alloc(), host, port)) @@ -1118,6 +1217,7 @@ private ChannelFuture openConnectionFuture() { case HTTP_1 -> new ChannelInitializer<>() { @Override protected void initChannel(@NonNull Channel ch) throws Exception { + insertPcapLoggingHandlerLazy(ch, "outer"); configureProxy(ch.pipeline(), false, requestKey.getHost(), requestKey.getPort()); initHttp1(ch); ch.pipeline().addLast(ChannelPipelineCustomizer.HANDLER_ACTIVITY_LISTENER, new ChannelInboundHandlerAdapter() { diff --git a/http-client/src/test/groovy/io/micronaut/http/client/netty/PcapLoggingSpec.groovy b/http-client/src/test/groovy/io/micronaut/http/client/netty/PcapLoggingSpec.groovy new file mode 100644 index 00000000000..57c9c321fb0 --- /dev/null +++ b/http-client/src/test/groovy/io/micronaut/http/client/netty/PcapLoggingSpec.groovy @@ -0,0 +1,63 @@ +package io.micronaut.http.client.netty + +import io.micronaut.context.ApplicationContext +import io.micronaut.http.client.HttpClient +import io.micronaut.runtime.server.EmbeddedServer +import reactor.core.publisher.Flux +import spock.lang.Specification + +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes +import java.util.stream.Collectors + +class PcapLoggingSpec extends Specification { + def 'pcap logging'() { + given: + def tmp = Files.createTempDirectory("micronaut-http-server-netty-test-pcap-logging-spec") + def ctx = ApplicationContext.run([ + 'micronaut.http.client.pcap-logging-path-pattern': tmp.toString() + '/{localAddress}-{remoteAddress}-{qualifier}-{random}-{timestamp}.pcap', + 'micronaut.ssl.enabled': true, + 'micronaut.ssl.buildSelfSigned': true, + 'micronaut.ssl.port': -1, + 'micronaut.http.client.ssl.insecure-trust-all-certificates': true, + ]) + def server = ctx.getBean(EmbeddedServer) + server.start() + def client = ctx.createBean(HttpClient, server.URI) + + expect: + Files.list(tmp).collect(Collectors.toList()).isEmpty() + + when: + try { + Flux.from(client.exchange('/')).blockLast() + } catch (ignored) { + // don't actually care about the response + } + def names = Files.list(tmp).map(p -> p.fileName.toString()).sorted().collect(Collectors.toList()) + then: + names.size() == 2 + names[0].matches('127\\.0\\.0\\.1_\\d+-127\\.0\\.0\\.1_\\d+-outer-\\w+-\\d+-\\d+-\\d+T\\d+_\\d+_\\d+\\.\\d+Z\\.pcap') + names[1].matches('127\\.0\\.0\\.1_\\d+-127\\.0\\.0\\.1_\\d+-tls-unwrapped-\\w+-\\d+-\\d+-\\d+T\\d+_\\d+_\\d+\\.\\d+Z\\.pcap') + + cleanup: + server.close() + client.close() + Files.walkFileTree(tmp, new SimpleFileVisitor() { + @Override + FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.delete(file) + return FileVisitResult.CONTINUE + } + + @Override + FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.delete(dir) + return FileVisitResult.CONTINUE + } + }) + } +} diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/HttpPipelineBuilder.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/HttpPipelineBuilder.java index 2caa735b950..1d6c1524161 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/HttpPipelineBuilder.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/HttpPipelineBuilder.java @@ -293,7 +293,7 @@ private String resolveIfNecessary(SocketAddress address) { if (address instanceof InetSocketAddress socketAddress) { if (socketAddress.isUnresolved()) { // try resolution - address = new InetSocketAddress(socketAddress.getHostString(), socketAddress.getPort()); + socketAddress = new InetSocketAddress(socketAddress.getHostString(), socketAddress.getPort()); if (socketAddress.isUnresolved()) { // resolution failed, bail return "unresolved";