Skip to content

Commit ec6a2d1

Browse files
committed
Add support for Ktor (testing the CIO engine specifically)
1 parent 2fe34f5 commit ec6a2d1

File tree

9 files changed

+165
-2
lines changed

9 files changed

+165
-2
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Traffic can be captured from at least:
2020
- [x] Async-Http-Client
2121
- [x] Reactor-Netty v0.9 & v1+
2222
- [x] Spring WebClient
23+
- [x] Ktor-Client
2324

2425
This will also capture HTTP(S) from any downstream libraries based on each of these clients, and many other untested clients sharing similar implementations, and so should cover a very large percentage of HTTP client usage.
2526

build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ dependencies {
3838
compileOnly group: 'org.eclipse.jetty', name: 'jetty-client', version: '11.0.1'
3939
compileOnly group: 'org.asynchttpclient', name: 'async-http-client', version: '2.12.2'
4040
compileOnly group: 'io.projectreactor.netty', name: 'reactor-netty', version: '1.0.4'
41+
compileOnly group: 'io.ktor', name: 'ktor-client-core', version: '1.5.2'
42+
compileOnly group: 'io.ktor', name: 'ktor-client-cio', version: '1.5.2'
4143

4244
// Test deps:
4345
testImplementation group: 'io.kotest', name: 'kotest-runner-junit5-jvm', version: '4.4.0'
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package tech.httptoolkit.javaagent.advice.ktor;
2+
3+
import io.ktor.network.tls.TLSConfig;
4+
import net.bytebuddy.asm.Advice;
5+
import tech.httptoolkit.javaagent.HttpProxyAgent;
6+
7+
import javax.net.ssl.X509TrustManager;
8+
import java.net.InetSocketAddress;
9+
import java.net.Proxy;
10+
11+
public class KtorResetProxyFieldAdvice {
12+
13+
@Advice.OnMethodEnter
14+
public static void beforeExecute(
15+
@Advice.FieldValue(value = "proxy", readOnly = false) Proxy proxyField
16+
) throws Exception {
17+
proxyField = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(
18+
HttpProxyAgent.getAgentProxyHost(),
19+
HttpProxyAgent.getAgentProxyPort()
20+
));
21+
}
22+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package tech.httptoolkit.javaagent.advice.ktor;
2+
3+
import io.ktor.network.tls.TLSConfig;
4+
import net.bytebuddy.asm.Advice;
5+
import tech.httptoolkit.javaagent.HttpProxyAgent;
6+
7+
import javax.net.ssl.X509TrustManager;
8+
9+
public class KtorResetTlsClientTrustAdvice {
10+
11+
@Advice.OnMethodEnter
12+
public static void beforeOpenTLSSession(
13+
@Advice.Argument(value = 3, readOnly = false) TLSConfig tlsClientConfig
14+
) throws Exception {
15+
// We're rewriting Kotlin bytecode from Java now, so some things get funky. Here it seems
16+
// that coroutines result in a double call where the outer call has no args, so we need this:
17+
if (tlsClientConfig == null) return;
18+
19+
// Clone the config, but replace the trust manager with one that trusts only our certificate:
20+
tlsClientConfig = new TLSConfig(
21+
tlsClientConfig.getRandom(),
22+
tlsClientConfig.getCertificates(),
23+
(X509TrustManager) HttpProxyAgent
24+
.getInterceptedTrustManagerFactory()
25+
.getTrustManagers()[0],
26+
tlsClientConfig.getCipherSuites(),
27+
tlsClientConfig.getServerName()
28+
);
29+
}
30+
}

src/main/kotlin/tech/httptoolkit/javaagent/AgentMain.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,10 @@ fun interceptAllHttps(config: Config, instrumentation: Instrumentation) {
100100
ReactorNettyClientConfigTransformer(logger),
101101
ReactorNettyProxyProviderTransformer(logger),
102102
ReactorNettyOverrideRequestAddressTransformer(logger),
103-
ReactorNettyHttpClientSecureTransformer(logger)
103+
ReactorNettyHttpClientSecureTransformer(logger),
104+
KtorClientEngineConfigTransformer(logger),
105+
KtorCioEngineTransformer(logger),
106+
KtorClientTlsTransformer(logger)
104107
).forEach { matchingAgentTransformer ->
105108
agentBuilder = matchingAgentTransformer.register(agentBuilder)
106109
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package tech.httptoolkit.javaagent
2+
3+
import net.bytebuddy.agent.builder.AgentBuilder
4+
import net.bytebuddy.asm.Advice
5+
import net.bytebuddy.description.method.MethodDescription
6+
import net.bytebuddy.dynamic.DynamicType
7+
import net.bytebuddy.matcher.ElementMatchers.*
8+
import tech.httptoolkit.javaagent.advice.ReturnProxyAdvice
9+
import tech.httptoolkit.javaagent.advice.ktor.KtorResetProxyFieldAdvice
10+
import tech.httptoolkit.javaagent.advice.ktor.KtorResetTlsClientTrustAdvice
11+
12+
// To intercept HTTPS, we need to change the trust manager in TLSConfig instances. We don't want
13+
// to mess with server config though, so we clone the config argument and replace it with one that
14+
// uses our custom trust manager instead, every time a new TLS session is opened:
15+
class KtorClientTlsTransformer(logger: TransformationLogger) : MatchingAgentTransformer(logger) {
16+
override fun register(builder: AgentBuilder): AgentBuilder {
17+
return builder
18+
.type(
19+
named("io.ktor.network.tls.TLSClientSessionJvmKt")
20+
).transform(this)
21+
}
22+
23+
override fun transform(builder: DynamicType.Builder<*>): DynamicType.Builder<*> {
24+
return builder
25+
.visit(Advice.to(KtorResetTlsClientTrustAdvice::class.java)
26+
.on(hasMethodName<MethodDescription>("openTLSSession")
27+
.and(takesArgument(3, named("io.ktor.network.tls.TLSConfig")))))
28+
}
29+
}
30+
31+
// Proxy configuration for new clients is easy: we just hook getProxy() in the engine
32+
// configuration to return our proxy.
33+
class KtorClientEngineConfigTransformer(logger: TransformationLogger) : MatchingAgentTransformer(logger) {
34+
override fun register(builder: AgentBuilder): AgentBuilder {
35+
return builder
36+
.type(
37+
named("io.ktor.client.engine.HttpClientEngineConfig")
38+
).transform(this)
39+
}
40+
41+
override fun transform(builder: DynamicType.Builder<*>): DynamicType.Builder<*> {
42+
return builder
43+
.visit(Advice.to(ReturnProxyAdvice::class.java)
44+
.on(hasMethodName("getProxy")))
45+
}
46+
}
47+
48+
// Proxy configuration for existing clients is only mildly harder: we hook individual engines
49+
// elsewhere anyway, so it shouldn't matter much, but not CIO which is ktor specific. For CIO,
50+
// we just need one more hook that resets the proxy field to the value from config (hooked above)
51+
// before any requests are executed:
52+
class KtorCioEngineTransformer(logger: TransformationLogger) : MatchingAgentTransformer(logger) {
53+
override fun register(builder: AgentBuilder): AgentBuilder {
54+
return builder
55+
.type(
56+
named("io.ktor.client.engine.cio.CIOEngine")
57+
).transform(this)
58+
}
59+
60+
override fun transform(builder: DynamicType.Builder<*>): DynamicType.Builder<*> {
61+
return builder
62+
.visit(Advice.to(KtorResetProxyFieldAdvice::class.java)
63+
.on(hasMethodName("execute")))
64+
}
65+
}
66+
67+
/**
68+
*
69+
*
70+
* Proxy settings live in HttpClientEngineConfig. Stored on CIOEngine at creation time,
71+
* then used in execute() only. Reset then?
72+
*
73+
* Yes: change getProxy settings (changes all new engines immediately, though others shouldn't
74+
* need it) and reset on execute()
75+
*
76+
*/

test-app/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
plugins {
22
id 'java'
33
id 'com.github.johnrengelman.shadow'
4+
id 'org.jetbrains.kotlin.jvm'
45
}
56

67
group 'tech.httptoolkit'
@@ -24,6 +25,9 @@ dependencies {
2425
implementation group: 'org.asynchttpclient', name: 'async-http-client', version: '2.12.2'
2526
implementation group: 'org.springframework', name: 'spring-webflux', version: '5.3.4'
2627
implementation group: 'io.projectreactor.netty', name: 'reactor-netty', version: '1.0.4'
28+
29+
implementation group: 'io.ktor', name: 'ktor-client-core', version: '1.5.2'
30+
implementation group: 'io.ktor', name: 'ktor-client-cio', version: '1.5.2'
2731
}
2832

2933
test {

test-app/src/main/java/tech/httptoolkit/testapp/Main.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ public class Main {
2727
entry("retrofit", new RetrofitCase()),
2828
entry("jetty-client", new JettyClientCase()),
2929
entry("async-http-client", new AsyncHttpClientCase()),
30-
entry("spring-web", new SpringWebClientCase())
30+
entry("spring-web", new SpringWebClientCase()),
31+
entry("ktor-cio", new KtorCioCase())
3132
);
3233

3334
public static void main(String[] args) throws Exception {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package tech.httptoolkit.testapp.cases
2+
3+
import io.ktor.client.*
4+
import io.ktor.client.engine.cio.*
5+
import io.ktor.client.request.*
6+
import io.ktor.client.statement.*
7+
import kotlinx.coroutines.runBlocking
8+
9+
class KtorCioCase : ClientCase<HttpClient>() {
10+
11+
override fun newClient(url: String): HttpClient {
12+
return HttpClient(CIO)
13+
}
14+
15+
override fun stopClient(client: HttpClient) {
16+
client.close()
17+
}
18+
19+
override fun test(url: String, client: HttpClient): Int = runBlocking {
20+
val response = client.get<HttpResponse>(url)
21+
response.status.value
22+
}
23+
24+
}

0 commit comments

Comments
 (0)