Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement pcap logging for netty HTTP client #11117

Open
wants to merge 1 commit into
base: 4.7.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -182,6 +183,8 @@ public abstract class HttpClientConfiguration {
@Nullable
private String addressResolverGroupName = null;

private String pcapLoggingPathPattern = null;

/**
* Default constructor.
*/
Expand Down Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -769,10 +859,15 @@ private final class AdaptiveAlpnChannelInitializer extends ChannelInitializer<Ch
protected void initChannel(@NonNull Channel ch) {
NettyClientCustomizer channelCustomizer = clientCustomizer.specializeForChannel(ch, NettyClientCustomizer.ChannelRole.CONNECTION);

insertPcapLoggingHandlerLazy(ch, "outer");

configureProxy(ch.pipeline(), true, host, port);

ch.pipeline().addLast(ChannelPipelineCustomizer.HANDLER_SSL, configureSslHandler(sslContext.newHandler(ch.alloc(), host, port)));

insertPcapLoggingHandlerLazy(ch, "tls-unwrapped");

ch.pipeline()
.addLast(ChannelPipelineCustomizer.HANDLER_SSL, configureSslHandler(sslContext.newHandler(ch.alloc(), host, port)))
.addLast(
ChannelPipelineCustomizer.HANDLER_HTTP2_PROTOCOL_NEGOTIATOR,
// if the server doesn't do ALPN, fall back to HTTP 1
Expand Down Expand Up @@ -833,6 +928,8 @@ private final class Http2UpgradeInitializer extends ChannelInitializer<Channel>
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();
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Path>() {
@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
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading