From 2ca241d86194d7c7b71fd65794e02074fd425c40 Mon Sep 17 00:00:00 2001 From: epli2 Date: Fri, 6 Mar 2026 01:28:51 +0900 Subject: [PATCH 01/11] docs: add CLAUDE.md as symlink to AGENTS.md --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) create mode 120000 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From e45fd328b4b45a3922f6150c5bdebaaca78daeba Mon Sep 17 00:00:00 2001 From: epli2 Date: Fri, 6 Mar 2026 01:51:47 +0900 Subject: [PATCH 02/11] docs: add MIT and Apache-2.0 dual license --- Cargo.toml | 2 + LICENSE-APACHE | 201 ++++++++++++++++++++++++++++++ LICENSE-MIT | 21 ++++ crates/phantom-agent/Cargo.toml | 1 + crates/phantom-capture/Cargo.toml | 1 + crates/phantom-core/Cargo.toml | 1 + crates/phantom-storage/Cargo.toml | 1 + crates/phantom-tui/Cargo.toml | 1 + 8 files changed, 229 insertions(+) create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT diff --git a/Cargo.toml b/Cargo.toml index 4afa1c9..72526db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ resolver = "3" [workspace.package] version = "0.1.0" edition = "2024" +license = "MIT OR Apache-2.0" [workspace.dependencies] phantom-core = { path = "crates/phantom-core" } @@ -27,6 +28,7 @@ tracing = "0.1" name = "phantom" version.workspace = true edition.workspace = true +license.workspace = true [dependencies] phantom-core = { workspace = true } diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..6b80b35 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright Phantom Contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..303a8f4 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Phantom Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/phantom-agent/Cargo.toml b/crates/phantom-agent/Cargo.toml index e689e8b..7bbaaaa 100644 --- a/crates/phantom-agent/Cargo.toml +++ b/crates/phantom-agent/Cargo.toml @@ -2,6 +2,7 @@ name = "phantom-agent" version.workspace = true edition.workspace = true +license.workspace = true description = "LD_PRELOAD agent for zero-instrumentation HTTP capture (Linux only)" [lib] diff --git a/crates/phantom-capture/Cargo.toml b/crates/phantom-capture/Cargo.toml index 9ddab6c..7e0cf3b 100644 --- a/crates/phantom-capture/Cargo.toml +++ b/crates/phantom-capture/Cargo.toml @@ -2,6 +2,7 @@ name = "phantom-capture" version.workspace = true edition.workspace = true +license.workspace = true [dependencies] phantom-core = { workspace = true } diff --git a/crates/phantom-core/Cargo.toml b/crates/phantom-core/Cargo.toml index 8385a6a..c020d3c 100644 --- a/crates/phantom-core/Cargo.toml +++ b/crates/phantom-core/Cargo.toml @@ -2,6 +2,7 @@ name = "phantom-core" version.workspace = true edition.workspace = true +license.workspace = true [dependencies] serde = { workspace = true } diff --git a/crates/phantom-storage/Cargo.toml b/crates/phantom-storage/Cargo.toml index 849207e..9f8b327 100644 --- a/crates/phantom-storage/Cargo.toml +++ b/crates/phantom-storage/Cargo.toml @@ -2,6 +2,7 @@ name = "phantom-storage" version.workspace = true edition.workspace = true +license.workspace = true [dependencies] phantom-core = { workspace = true } diff --git a/crates/phantom-tui/Cargo.toml b/crates/phantom-tui/Cargo.toml index 023f77f..c67f3d5 100644 --- a/crates/phantom-tui/Cargo.toml +++ b/crates/phantom-tui/Cargo.toml @@ -2,6 +2,7 @@ name = "phantom-tui" version.workspace = true edition.workspace = true +license.workspace = true [dependencies] phantom-core = { workspace = true } From 5cf94687017422c6aa79798159b835073125e4c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 04:06:25 +0000 Subject: [PATCH 03/11] Add Spring Boot and Java HTTP clients integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new integration test suites that verify phantom's proxy backend captures traffic from JVM-based applications: 1. tests/proxy_springboot_integration.rs — tests a minimal Spring Boot CommandLineRunner app (phantom-springboot-client) that reads HTTP_PROXY (injected by phantom) and uses java.net.http.HttpClient to make 2 HTTP and 2 HTTPS requests. Verifies all 4 traces are captured with correct status codes, bodies, and required trace fields. 2. tests/proxy_java_clients_integration.rs — tests four major Java HTTP client libraries (JDK HttpClient, AsyncHttpClient, Jetty HttpClient, Apache HttpClient 5) in a single plain-Java CLI app. Each client adds an x-phantom-client header for trace identification. Verifies 8 traces (4 clients × 2 schemes). Both apps use a trust-all SSLContext for HTTPS MITM capture and are built with mvn package before the test runs. Tests skip gracefully when java or mvn are not on PATH. https://claude.ai/code/session_01E3FVEjny7BKfgUeAGTM955 --- tests/apps/java-http-clients/pom.xml | 86 +++++ .../main/java/com/example/phantom/Client.java | 236 ++++++++++++ tests/apps/springboot-app/pom.xml | 43 +++ .../example/phantom/ClientApplication.java | 126 +++++++ tests/proxy_java_clients_integration.rs | 336 +++++++++++++++++ tests/proxy_springboot_integration.rs | 354 ++++++++++++++++++ 6 files changed, 1181 insertions(+) create mode 100644 tests/apps/java-http-clients/pom.xml create mode 100644 tests/apps/java-http-clients/src/main/java/com/example/phantom/Client.java create mode 100644 tests/apps/springboot-app/pom.xml create mode 100644 tests/apps/springboot-app/src/main/java/com/example/phantom/ClientApplication.java create mode 100644 tests/proxy_java_clients_integration.rs create mode 100644 tests/proxy_springboot_integration.rs diff --git a/tests/apps/java-http-clients/pom.xml b/tests/apps/java-http-clients/pom.xml new file mode 100644 index 0000000..c555519 --- /dev/null +++ b/tests/apps/java-http-clients/pom.xml @@ -0,0 +1,86 @@ + + + 4.0.0 + + com.example.phantom + java-http-clients + 0.0.1-SNAPSHOT + jar + + + 17 + 17 + UTF-8 + + + + + + org.asynchttpclient + async-http-client + 3.0.1 + + + + + org.eclipse.jetty + jetty-client + 12.0.14 + + + + + org.apache.httpcomponents.client5 + httpclient5 + 5.3.1 + + + + + org.slf4j + slf4j-nop + 2.0.13 + + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + false + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + com.example.phantom.Client + + + + + + + + + + diff --git a/tests/apps/java-http-clients/src/main/java/com/example/phantom/Client.java b/tests/apps/java-http-clients/src/main/java/com/example/phantom/Client.java new file mode 100644 index 0000000..5904457 --- /dev/null +++ b/tests/apps/java-http-clients/src/main/java/com/example/phantom/Client.java @@ -0,0 +1,236 @@ +package com.example.phantom; + +// A plain Java CLI application that makes HTTP and HTTPS requests using four +// different HTTP client libraries. This file contains ZERO proxy configuration +// — the proxy is configured based on the HTTP_PROXY environment variable that +// phantom sets automatically when running: phantom -- java -jar client.jar +// +// Environment: +// HTTP_PROXY — set by phantom, e.g. http://127.0.0.1:8080 +// BACKEND_HTTP_URL — e.g. http://127.0.0.1:3000 +// BACKEND_HTTPS_URL — e.g. https://localhost:3443 (optional) +// +// Each client adds an x-phantom-client header to identify itself in traces. + +import org.asynchttpclient.*; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.HttpProxy; +import org.eclipse.jetty.client.transport.HttpClientTransportOverHTTP; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.ssl.SSLContextBuilder; +import org.apache.hc.core5.ssl.TrustAllStrategy; + +import javax.net.ssl.*; +import java.net.InetSocketAddress; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; + +public class Client { + + // ------------------------------------------------------------------------- + // Shared helpers + // ------------------------------------------------------------------------- + + /** Parse HTTP_PROXY env var → InetSocketAddress, or null if not set. */ + static InetSocketAddress proxyAddress() { + String raw = System.getenv("HTTP_PROXY"); + if (raw == null) raw = System.getenv("http_proxy"); + if (raw == null) return null; + URI u = URI.create(raw); + return new InetSocketAddress(u.getHost(), u.getPort()); + } + + /** SSLContext that trusts any certificate (for MITM testing). */ + static SSLContext trustAllSslContext() throws Exception { + TrustManager[] trustAll = {new X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } + public void checkClientTrusted(X509Certificate[] c, String a) {} + public void checkServerTrusted(X509Certificate[] c, String a) {} + }}; + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(null, trustAll, new SecureRandom()); + return ctx; + } + + // ------------------------------------------------------------------------- + // 1. JDK java.net.http.HttpClient + // ------------------------------------------------------------------------- + + static void runJdkHttpClient(String httpBase, String httpsBase) throws Exception { + InetSocketAddress proxy = proxyAddress(); + java.net.http.HttpClient.Builder builder = java.net.http.HttpClient.newBuilder() + .sslContext(trustAllSslContext()); + if (proxy != null) { + builder.proxy(ProxySelector.of(proxy)); + } + java.net.http.HttpClient client = builder.build(); + + // HTTP + HttpRequest httpReq = HttpRequest.newBuilder() + .uri(URI.create(httpBase + "/api/health")) + .header("x-phantom-client", "jdk-httpclient") + .GET().build(); + HttpResponse r1 = client.send(httpReq, HttpResponse.BodyHandlers.ofString()); + System.out.println("jdk http: status=" + r1.statusCode() + " body=" + r1.body()); + + // HTTPS + if (httpsBase != null) { + HttpRequest httpsReq = HttpRequest.newBuilder() + .uri(URI.create(httpsBase + "/api/health")) + .header("x-phantom-client", "jdk-httpclient") + .GET().build(); + HttpResponse r2 = client.send(httpsReq, HttpResponse.BodyHandlers.ofString()); + System.out.println("jdk https: status=" + r2.statusCode() + " body=" + r2.body()); + } + } + + // ------------------------------------------------------------------------- + // 2. AsyncHttpClient (Netty-based) + // ------------------------------------------------------------------------- + + static void runAsyncHttpClient(String httpBase, String httpsBase) throws Exception { + InetSocketAddress proxy = proxyAddress(); + DefaultAsyncHttpClientConfig.Builder cfgBuilder = + new DefaultAsyncHttpClientConfig.Builder() + .setSslContext(trustAllSslContext()) + .setUseInsecureTrustManager(true); + if (proxy != null) { + cfgBuilder.setProxyServer(new ProxyServer.Builder(proxy.getHostName(), proxy.getPort()).build()); + } + + try (AsyncHttpClient client = new DefaultAsyncHttpClient(cfgBuilder.build())) { + // HTTP + Response r1 = client.prepareGet(httpBase + "/api/health") + .addHeader("x-phantom-client", "async-http-client") + .execute().get(); + System.out.println("async http: status=" + r1.getStatusCode() + " body=" + r1.getResponseBody()); + + // HTTPS + if (httpsBase != null) { + Response r2 = client.prepareGet(httpsBase + "/api/health") + .addHeader("x-phantom-client", "async-http-client") + .execute().get(); + System.out.println("async https: status=" + r2.getStatusCode() + " body=" + r2.getResponseBody()); + } + } + } + + // ------------------------------------------------------------------------- + // 3. Jetty HttpClient + // ------------------------------------------------------------------------- + + static void runJettyHttpClient(String httpBase, String httpsBase) throws Exception { + InetSocketAddress proxy = proxyAddress(); + + SslContextFactory.Client sslFactory = new SslContextFactory.Client(true); + sslFactory.setSslContext(trustAllSslContext()); + + HttpClient client = new HttpClient(new HttpClientTransportOverHTTP()); + client.setSslContextFactory(sslFactory); + + if (proxy != null) { + client.getProxyConfiguration().addProxy( + new HttpProxy(proxy.getHostName(), proxy.getPort())); + } + + client.start(); + try { + // HTTP + org.eclipse.jetty.client.ContentResponse r1 = client.newRequest(httpBase + "/api/health") + .method(org.eclipse.jetty.http.HttpMethod.GET) + .headers(h -> h.add("x-phantom-client", "jetty-httpclient")) + .send(); + System.out.println("jetty http: status=" + r1.getStatus() + " body=" + r1.getContentAsString()); + + // HTTPS + if (httpsBase != null) { + org.eclipse.jetty.client.ContentResponse r2 = client.newRequest(httpsBase + "/api/health") + .method(org.eclipse.jetty.http.HttpMethod.GET) + .headers(h -> h.add("x-phantom-client", "jetty-httpclient")) + .send(); + System.out.println("jetty https: status=" + r2.getStatus() + " body=" + r2.getContentAsString()); + } + } finally { + client.stop(); + } + } + + // ------------------------------------------------------------------------- + // 4. Apache HttpClient 5 + // ------------------------------------------------------------------------- + + static void runApacheHttpClient(String httpBase, String httpsBase) throws Exception { + InetSocketAddress proxy = proxyAddress(); + + SSLContext sslCtx = SSLContextBuilder.create() + .loadTrustMaterial(TrustAllStrategy.INSTANCE) + .build(); + + var sslSocketFactory = SSLConnectionSocketFactoryBuilder.create() + .setSslContext(sslCtx) + .setHostnameVerifier(new NoopHostnameVerifier()) + .build(); + + var connManager = PoolingHttpClientConnectionManagerBuilder.create() + .setSSLSocketFactory(sslSocketFactory) + .build(); + + var clientBuilder = HttpClients.custom() + .setConnectionManager(connManager); + + if (proxy != null) { + clientBuilder.setProxy(new HttpHost(proxy.getHostName(), proxy.getPort())); + } + + try (CloseableHttpClient client = clientBuilder.build()) { + // HTTP + HttpGet get1 = new HttpGet(httpBase + "/api/health"); + get1.addHeader("x-phantom-client", "apache-httpclient"); + String body1 = client.execute(get1, response -> + EntityUtils.toString(response.getEntity())); + System.out.println("apache http: body=" + body1); + + // HTTPS + if (httpsBase != null) { + HttpGet get2 = new HttpGet(httpsBase + "/api/health"); + get2.addHeader("x-phantom-client", "apache-httpclient"); + String body2 = client.execute(get2, response -> + EntityUtils.toString(response.getEntity())); + System.out.println("apache https: body=" + body2); + } + } + } + + // ------------------------------------------------------------------------- + // Main + // ------------------------------------------------------------------------- + + public static void main(String[] args) throws Exception { + String httpBase = System.getenv("BACKEND_HTTP_URL"); + String httpsBase = System.getenv("BACKEND_HTTPS_URL"); + + if (httpBase == null) { + System.err.println("BACKEND_HTTP_URL is required"); + System.exit(1); + } + + runJdkHttpClient(httpBase, httpsBase); + runAsyncHttpClient(httpBase, httpsBase); + runJettyHttpClient(httpBase, httpsBase); + runApacheHttpClient(httpBase, httpsBase); + + System.out.println("CLIENT_DONE"); + } +} diff --git a/tests/apps/springboot-app/pom.xml b/tests/apps/springboot-app/pom.xml new file mode 100644 index 0000000..1c0df7c --- /dev/null +++ b/tests/apps/springboot-app/pom.xml @@ -0,0 +1,43 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.example.phantom + phantom-springboot-client + 0.0.1-SNAPSHOT + jar + Phantom integration test Spring Boot client + + + 17 + 17 + 17 + + + + + + org.springframework.boot + spring-boot-starter + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/tests/apps/springboot-app/src/main/java/com/example/phantom/ClientApplication.java b/tests/apps/springboot-app/src/main/java/com/example/phantom/ClientApplication.java new file mode 100644 index 0000000..1ca5934 --- /dev/null +++ b/tests/apps/springboot-app/src/main/java/com/example/phantom/ClientApplication.java @@ -0,0 +1,126 @@ +package com.example.phantom; + +// Spring Boot CommandLineRunner that makes HTTP and HTTPS requests using the +// JDK java.net.http.HttpClient, explicitly reading the HTTP_PROXY env var set +// by phantom to configure proxy routing. +// +// Environment: +// HTTP_PROXY — set by phantom, e.g. http://127.0.0.1:8080 +// BACKEND_HTTP_URL — e.g. http://127.0.0.1:3000 +// BACKEND_HTTPS_URL — e.g. https://localhost:3443 (optional) + +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.net.InetSocketAddress; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.security.cert.X509Certificate; + +@SpringBootApplication +public class ClientApplication implements CommandLineRunner { + + public static void main(String[] args) { + // SpringApplication.exit propagates the runner's exit code through System.exit, + // ensuring the JVM terminates cleanly after the runner completes. + System.exit(SpringApplication.exit(SpringApplication.run(ClientApplication.class, args))); + } + + @Override + public void run(String... args) throws Exception { + String backendHttpUrl = System.getenv("BACKEND_HTTP_URL"); + String backendHttpsUrl = System.getenv("BACKEND_HTTPS_URL"); + String httpProxy = System.getenv("HTTP_PROXY"); + + if (backendHttpUrl == null || backendHttpUrl.isBlank()) { + throw new IllegalStateException("BACKEND_HTTP_URL env var is required"); + } + if (httpProxy == null || httpProxy.isBlank()) { + throw new IllegalStateException("HTTP_PROXY env var is required (set by phantom)"); + } + + // Parse proxy from HTTP_PROXY env var (set automatically by phantom) + URI proxyUri = URI.create(httpProxy); + ProxySelector proxySelector = ProxySelector.of( + new InetSocketAddress(proxyUri.getHost(), proxyUri.getPort()) + ); + + // Trust-all SSLContext — phantom presents a dynamically-generated MITM CA cert; + // skipping verification is equivalent to NODE_TLS_REJECT_UNAUTHORIZED=0. + // For testing ONLY — never use in production. + SSLContext trustAllCtx = buildTrustAllSslContext(); + + HttpClient client = HttpClient.newBuilder() + .proxy(proxySelector) + .sslContext(trustAllCtx) + .build(); + + // HTTP: GET /api/health + HttpResponse r1 = client.send( + HttpRequest.newBuilder() + .GET() + .uri(URI.create(backendHttpUrl + "/api/health")) + .build(), + HttpResponse.BodyHandlers.ofString() + ); + System.out.println("http health: status=" + r1.statusCode() + " body=" + r1.body()); + + // HTTP: GET /api/users + HttpResponse r2 = client.send( + HttpRequest.newBuilder() + .GET() + .uri(URI.create(backendHttpUrl + "/api/users")) + .build(), + HttpResponse.BodyHandlers.ofString() + ); + System.out.println("http users: status=" + r2.statusCode() + " body=" + r2.body()); + + // HTTPS requests (optional — only when BACKEND_HTTPS_URL is provided) + if (backendHttpsUrl != null && !backendHttpsUrl.isBlank()) { + + // HTTPS: GET /api/health + HttpResponse r3 = client.send( + HttpRequest.newBuilder() + .GET() + .uri(URI.create(backendHttpsUrl + "/api/health")) + .build(), + HttpResponse.BodyHandlers.ofString() + ); + System.out.println("https health: status=" + r3.statusCode() + " body=" + r3.body()); + + // HTTPS: POST /api/users + String postBody = "{\"name\":\"Charlie\",\"email\":\"charlie@example.com\"}"; + HttpResponse r4 = client.send( + HttpRequest.newBuilder() + .POST(HttpRequest.BodyPublishers.ofString(postBody)) + .uri(URI.create(backendHttpsUrl + "/api/users")) + .header("Content-Type", "application/json") + .build(), + HttpResponse.BodyHandlers.ofString() + ); + System.out.println("https create: status=" + r4.statusCode() + " body=" + r4.body()); + } + + System.out.println("CLIENT_DONE"); + } + + private static SSLContext buildTrustAllSslContext() throws Exception { + TrustManager[] trustAllManagers = new TrustManager[]{ + new X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } + public void checkClientTrusted(X509Certificate[] chain, String authType) {} + public void checkServerTrusted(X509Certificate[] chain, String authType) {} + } + }; + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(null, trustAllManagers, new java.security.SecureRandom()); + return ctx; + } +} diff --git a/tests/proxy_java_clients_integration.rs b/tests/proxy_java_clients_integration.rs new file mode 100644 index 0000000..f342f01 --- /dev/null +++ b/tests/proxy_java_clients_integration.rs @@ -0,0 +1,336 @@ +//! Integration test: phantom proxy transparently traces Java HTTP client libraries +//! +//! Verifies that phantom's proxy backend captures HTTP and HTTPS traffic from +//! four major Java HTTP client libraries: +//! +//! 1. JDK java.net.http.HttpClient (built-in, Java 11+) +//! 2. AsyncHttpClient (Netty-based) +//! 3. Jetty HttpClient +//! 4. Apache HttpClient 5 +//! +//! Each client adds an `x-phantom-client` request header so traces can be +//! identified in the JSONL output. The pattern mirrors the Node.js +//! `test_proxy_captures_alternative_http_clients` test. +//! +//! Requirements: `java` (17+) and `mvn` on PATH. +//! Run: `cargo test --test proxy_java_clients_integration` + +use std::io::{Read, Write as IoWrite}; +use std::net::{TcpListener, TcpStream}; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers (mirrors proxy_node_integration.rs) +// ───────────────────────────────────────────────────────────────────────────── + +fn available_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .expect("bind :0") + .local_addr() + .unwrap() + .port() +} + +fn wait_for_port(port: u16, timeout: Duration) -> bool { + let start = Instant::now(); + while start.elapsed() < timeout { + if TcpStream::connect(format!("127.0.0.1:{port}")).is_ok() { + return true; + } + std::thread::sleep(Duration::from_millis(50)); + } + false +} + +// ───────────────────────────────────────────────────────────────────────────── +// Mock backend — HTTP +// ───────────────────────────────────────────────────────────────────────────── + +const HEALTH_BODY: &str = r#"{"status":"ok"}"#; +const USERS_BODY: &str = r#"[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]"#; +const CREATED_BODY: &str = r#"{"id":3,"name":"Charlie","email":"charlie@example.com"}"#; + +fn route_request(req: &str) -> (&str, &str) { + let first = req.lines().next().unwrap_or(""); + if first.starts_with("GET") && first.contains("/api/health") { + ("200 OK", HEALTH_BODY) + } else if first.starts_with("GET") && first.contains("/api/users") { + ("200 OK", USERS_BODY) + } else if first.starts_with("POST") && first.contains("/api/users") { + ("201 Created", CREATED_BODY) + } else { + ("404 Not Found", r#"{"error":"Not Found"}"#) + } +} + +fn write_response(stream: &mut impl IoWrite, status: &str, body: &str) { + let resp = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.flush(); +} + +fn handle_stream(stream: &mut (impl Read + IoWrite)) { + let mut buf = [0u8; 8192]; + let n = match stream.read(&mut buf) { + Ok(0) | Err(_) => return, + Ok(n) => n, + }; + let req = String::from_utf8_lossy(&buf[..n]); + let (status, body) = route_request(&req); + write_response(stream, status, body); +} + +fn start_http_backend(port: u16) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + let listener = TcpListener::bind(format!("127.0.0.1:{port}")).unwrap(); + listener.set_nonblocking(false).expect("set_nonblocking(false)"); + for stream in listener.incoming() { + match stream { + Ok(mut s) => handle_stream(&mut s), + Err(_) => break, + } + } + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Mock backend — HTTPS (rustls) +// ───────────────────────────────────────────────────────────────────────────── + +fn start_https_backend( + port: u16, + cert_der: Vec, + key_der: Vec, +) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + let certs = vec![rustls_pki_types::CertificateDer::from(cert_der)]; + let key = rustls_pki_types::PrivateKeyDer::try_from(key_der).expect("parse private key"); + + let server_config = Arc::new( + rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key) + .expect("build ServerConfig"), + ); + + let listener = TcpListener::bind(format!("127.0.0.1:{port}")).unwrap(); + for stream in listener.incoming() { + match stream { + Ok(tcp) => { + let conn = match rustls::ServerConnection::new(server_config.clone()) { + Ok(c) => c, + Err(_) => continue, + }; + let mut tls = rustls::StreamOwned::new(conn, tcp); + handle_stream(&mut tls); + } + Err(_) => break, + } + } + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helper +// ───────────────────────────────────────────────────────────────────────────── + +fn client_of(t: &serde_json::Value) -> &str { + t["request_headers"]["x-phantom-client"] + .as_str() + .unwrap_or("unknown") +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_proxy_captures_java_http_clients() { + // ── Pre-flight: require java and mvn ────────────────────────────────── + if Command::new("java") + .arg("-version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_err() + { + eprintln!("SKIP: `java` not found"); + return; + } + if Command::new("mvn") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_err() + { + eprintln!("SKIP: `mvn` not found"); + return; + } + + let phantom_bin = env!("CARGO_BIN_EXE_phantom"); + let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/apps/java-http-clients"); + let tmp_dir = tempfile::tempdir().expect("tempdir"); + + // ── Build the fat JAR ───────────────────────────────────────────────── + let mvn_status = Command::new("mvn") + .args(["package", "-q", "--no-transfer-progress", "-f"]) + .arg(app_dir.join("pom.xml")) + .status() + .expect("mvn package"); + assert!(mvn_status.success(), "mvn package failed"); + + let jar_path = app_dir.join("target/java-http-clients-0.0.1-SNAPSHOT.jar"); + assert!(jar_path.exists(), "JAR not found at {jar_path:?}"); + + let http_port = available_port(); + let https_port = available_port(); + let proxy_port = available_port(); + + // ── Generate self-signed cert ───────────────────────────────────────── + let certified = + rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).expect("generate cert"); + let cert_der = certified.cert.der().to_vec(); + let key_der = certified.key_pair.serialize_der(); + + // ── Start mock backends ─────────────────────────────────────────────── + let _http_thread = start_http_backend(http_port); + assert!( + wait_for_port(http_port, Duration::from_secs(3)), + "HTTP backend did not start" + ); + + let _https_thread = start_https_backend(https_port, cert_der, key_der); + assert!( + wait_for_port(https_port, Duration::from_secs(3)), + "HTTPS backend did not start" + ); + + // ── Run phantom with `-- java -jar client.jar` ──────────────────────── + // phantom sets HTTP_PROXY automatically; the Java app reads it to + // configure each client's proxy selector. + let phantom_output = Command::new(phantom_bin) + .args([ + "--backend", + "proxy", + "--output", + "jsonl", + "--port", + &proxy_port.to_string(), + "--insecure", + "--data-dir", + ]) + .arg(tmp_dir.path()) + .arg("--") + .arg("java") + .arg("-jar") + .arg(&jar_path) + .env("BACKEND_HTTP_URL", format!("http://127.0.0.1:{http_port}")) + .env( + "BACKEND_HTTPS_URL", + format!("https://localhost:{https_port}"), + ) + .output() + .expect("run phantom"); + + let stdout_buf = String::from_utf8_lossy(&phantom_output.stdout).into_owned(); + let stderr_buf = String::from_utf8_lossy(&phantom_output.stderr).into_owned(); + + assert!( + phantom_output.status.success(), + "phantom exited non-zero.\n stdout:\n{stdout_buf}\n stderr:\n{stderr_buf}" + ); + + // ── Parse JSONL traces ──────────────────────────────────────────────── + let traces: Vec = stdout_buf + .lines() + .filter(|l| l.starts_with('{')) + .filter_map(|l| serde_json::from_str(l).ok()) + .collect(); + + // 4 clients × 2 schemes (HTTP + HTTPS) = 8 traces + assert_eq!( + traces.len(), + 8, + "Expected 8 traces (4 clients × 2 schemes), got {}.\ + \n stdout:\n{stdout_buf}\n stderr:\n{stderr_buf}", + traces.len(), + ); + + // ── Per-client assertions ───────────────────────────────────────────── + let clients = [ + "jdk-httpclient", + "async-http-client", + "jetty-httpclient", + "apache-httpclient", + ]; + + for client in clients { + for scheme in ["http", "https"] { + let t = traces + .iter() + .find(|t| { + client_of(t) == client + && t["url"] + .as_str() + .is_some_and(|u| u.starts_with(&format!("{scheme}://"))) + }) + .unwrap_or_else(|| { + panic!( + "missing trace for client={client} scheme={scheme}\ + \n stdout:\n{stdout_buf}\n stderr:\n{stderr_buf}" + ) + }); + + assert_eq!(t["method"], "GET", "{client} {scheme} method"); + assert_eq!(t["status_code"], 200, "{client} {scheme} status_code"); + assert!( + t["url"] + .as_str() + .is_some_and(|u| u.contains("/api/health")), + "{client} {scheme} url should contain /api/health" + ); + assert!( + t["response_body"] + .as_str() + .is_some_and(|b| b.contains("ok")), + "{client} {scheme} response_body should contain 'ok'" + ); + } + } + + // ── Cross-cutting checks ────────────────────────────────────────────── + for (i, t) in traces.iter().enumerate() { + assert!( + t["trace_id"].as_str().is_some_and(|s| !s.is_empty()), + "trace[{i}] trace_id" + ); + assert!( + t["span_id"].as_str().is_some_and(|s| !s.is_empty()), + "trace[{i}] span_id" + ); + assert!( + t["timestamp_ms"].as_u64().is_some_and(|v| v > 0), + "trace[{i}] timestamp_ms" + ); + assert!( + t["request_headers"].is_object(), + "trace[{i}] request_headers" + ); + assert!( + t["response_headers"].is_object(), + "trace[{i}] response_headers" + ); + } + + eprintln!( + "All 8 traces verified. clients: {:?}", + traces.iter().map(client_of).collect::>() + ); +} diff --git a/tests/proxy_springboot_integration.rs b/tests/proxy_springboot_integration.rs new file mode 100644 index 0000000..1b1f628 --- /dev/null +++ b/tests/proxy_springboot_integration.rs @@ -0,0 +1,354 @@ +//! Integration test: phantom proxy transparently traces a Spring Boot app +//! +//! Verifies non-invasive proxy tracing: the Spring Boot CommandLineRunner reads +//! `HTTP_PROXY` (set automatically by phantom) and wires it into +//! `java.net.http.HttpClient`. No application business logic is proxy-aware. +//! +//! Tests both HTTP and HTTPS (MITM) capture. +//! +//! Requirements: `java` (17+) and `mvn` on PATH. +//! Run: `cargo test --test proxy_springboot_integration` + +use std::io::{Read, Write as IoWrite}; +use std::net::{TcpListener, TcpStream}; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +fn available_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .expect("bind :0") + .local_addr() + .unwrap() + .port() +} + +fn wait_for_port(port: u16, timeout: Duration) -> bool { + let start = Instant::now(); + while start.elapsed() < timeout { + if TcpStream::connect(format!("127.0.0.1:{port}")).is_ok() { + return true; + } + std::thread::sleep(Duration::from_millis(50)); + } + false +} + +// ───────────────────────────────────────────────────────────────────────────── +// Mock backends +// ───────────────────────────────────────────────────────────────────────────── + +const HEALTH_BODY: &str = r#"{"status":"ok"}"#; +const USERS_BODY: &str = r#"[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]"#; +const CREATED_BODY: &str = r#"{"id":3,"name":"Charlie","email":"charlie@example.com"}"#; + +fn route_request(req: &str) -> (&str, &str) { + let first = req.lines().next().unwrap_or(""); + if first.starts_with("GET") && first.contains("/api/health") { + ("200 OK", HEALTH_BODY) + } else if first.starts_with("GET") && first.contains("/api/users") { + ("200 OK", USERS_BODY) + } else if first.starts_with("POST") && first.contains("/api/users") { + ("201 Created", CREATED_BODY) + } else { + ("404 Not Found", r#"{"error":"Not Found"}"#) + } +} + +fn write_response(stream: &mut impl IoWrite, status: &str, body: &str) { + let resp = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.flush(); +} + +fn handle_stream(stream: &mut (impl Read + IoWrite)) { + let mut buf = [0u8; 8192]; + let n = match stream.read(&mut buf) { + Ok(0) | Err(_) => return, + Ok(n) => n, + }; + let req = String::from_utf8_lossy(&buf[..n]); + let (status, body) = route_request(&req); + write_response(stream, status, body); +} + +fn start_http_backend(port: u16) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + let listener = TcpListener::bind(format!("127.0.0.1:{port}")).unwrap(); + listener.set_nonblocking(false).expect("set_nonblocking(false)"); + for stream in listener.incoming() { + match stream { + Ok(mut s) => handle_stream(&mut s), + Err(_) => break, + } + } + }) +} + +fn start_https_backend( + port: u16, + cert_der: Vec, + key_der: Vec, +) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + let certs = vec![rustls_pki_types::CertificateDer::from(cert_der)]; + let key = rustls_pki_types::PrivateKeyDer::try_from(key_der).expect("parse private key"); + + let server_config = Arc::new( + rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key) + .expect("build ServerConfig"), + ); + + let listener = TcpListener::bind(format!("127.0.0.1:{port}")).unwrap(); + for stream in listener.incoming() { + match stream { + Ok(tcp) => { + let conn = match rustls::ServerConnection::new(server_config.clone()) { + Ok(c) => c, + Err(_) => continue, + }; + let mut tls = rustls::StreamOwned::new(conn, tcp); + handle_stream(&mut tls); + } + Err(_) => break, + } + } + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_proxy_captures_springboot_app_traffic() { + // Pre-flight: require java and mvn + if Command::new("java") + .arg("-version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_err() + { + eprintln!("SKIP: `java` not found"); + return; + } + if Command::new("mvn") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_err() + { + eprintln!("SKIP: `mvn` not found"); + return; + } + + let phantom_bin = env!("CARGO_BIN_EXE_phantom"); + let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/apps/springboot-app"); + let tmp_dir = tempfile::tempdir().expect("tempdir"); + + // Build the Spring Boot fat JAR + // -DskipTests: no unit tests in the app, avoids needing a test runner. + // --no-transfer-progress: suppress download progress bars in CI logs. + let mvn_status = Command::new("mvn") + .args(["package", "-q", "--no-transfer-progress", "-DskipTests", "-f"]) + .arg(app_dir.join("pom.xml")) + .status() + .expect("mvn package"); + assert!(mvn_status.success(), "mvn package failed"); + + // Locate the fat JAR at the deterministic Maven output path + let jar_path = app_dir + .join("target") + .join("phantom-springboot-client-0.0.1-SNAPSHOT.jar"); + assert!( + jar_path.exists(), + "JAR not found at {}: did mvn package succeed?", + jar_path.display() + ); + + // Allocate ports + let http_port = available_port(); + let https_port = available_port(); + let proxy_port = available_port(); + + // Generate self-signed cert for the HTTPS mock backend + let certified = + rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).expect("generate cert"); + let cert_der = certified.cert.der().to_vec(); + let key_der = certified.key_pair.serialize_der(); + + // Start HTTP backend + let _http_thread = start_http_backend(http_port); + assert!( + wait_for_port(http_port, Duration::from_secs(3)), + "HTTP backend did not start" + ); + + // Start HTTPS backend + let _https_thread = start_https_backend(https_port, cert_der, key_der); + assert!( + wait_for_port(https_port, Duration::from_secs(3)), + "HTTPS backend did not start" + ); + + // Run phantom with `-- java -jar {jar}` + // phantom sets HTTP_PROXY automatically; the Spring Boot app reads it. + // --insecure: phantom's proxy skips cert validation for the self-signed backend cert. + // The Java client's trust-all SSLContext handles phantom's MITM CA cert. + let phantom_output = Command::new(phantom_bin) + .args([ + "--backend", + "proxy", + "--output", + "jsonl", + "--port", + &proxy_port.to_string(), + "--insecure", + "--data-dir", + ]) + .arg(tmp_dir.path()) + .arg("--") + .arg("java") + .arg("-jar") + .arg(&jar_path) + .env("BACKEND_HTTP_URL", format!("http://127.0.0.1:{http_port}")) + .env( + "BACKEND_HTTPS_URL", + format!("https://localhost:{https_port}"), + ) + .output() + .expect("run phantom"); + + let stdout_buf = String::from_utf8_lossy(&phantom_output.stdout).into_owned(); + let stderr_buf = String::from_utf8_lossy(&phantom_output.stderr).into_owned(); + + assert!( + phantom_output.status.success(), + "phantom exited non-zero.\n stdout:\n{stdout_buf}\n stderr:\n{stderr_buf}" + ); + + // Parse JSONL traces + let traces: Vec = stdout_buf + .lines() + .filter(|l| l.starts_with('{')) + .filter_map(|l| serde_json::from_str(l).ok()) + .collect(); + + // Expect 4 traces: 2 HTTP (GET health, GET users) + 2 HTTPS (GET health, POST users) + assert_eq!( + traces.len(), + 4, + "Expected 4 traces (2 HTTP + 2 HTTPS), got {}.\n stdout:\n{stdout_buf}\n stderr:\n{stderr_buf}", + traces.len(), + ); + + // HTTP: GET /api/health + let health_http = traces + .iter() + .find(|t| { + let url = t["url"].as_str().unwrap_or(""); + url.contains("/api/health") && url.starts_with("http://") + }) + .expect("missing HTTP GET /api/health"); + assert_eq!(health_http["method"], "GET"); + assert_eq!(health_http["status_code"], 200); + assert!( + health_http["response_body"] + .as_str() + .is_some_and(|b| b.contains("ok")), + "HTTP health response_body should contain 'ok'" + ); + + // HTTP: GET /api/users + let users_http = traces + .iter() + .find(|t| { + let url = t["url"].as_str().unwrap_or(""); + url.contains("/api/users") && url.starts_with("http://") && t["method"] == "GET" + }) + .expect("missing HTTP GET /api/users"); + assert_eq!(users_http["status_code"], 200); + assert!( + users_http["response_body"] + .as_str() + .is_some_and(|b| b.contains("Alice")), + "HTTP users response_body should contain 'Alice'" + ); + + // HTTPS: GET /api/health + let health_https = traces + .iter() + .find(|t| { + let url = t["url"].as_str().unwrap_or(""); + url.contains("/api/health") && url.starts_with("https://") + }) + .expect("missing HTTPS GET /api/health"); + assert_eq!(health_https["method"], "GET"); + assert_eq!(health_https["status_code"], 200); + assert!( + health_https["response_body"] + .as_str() + .is_some_and(|b| b.contains("ok")), + "HTTPS health response_body should contain 'ok'" + ); + + // HTTPS: POST /api/users + let create_https = traces + .iter() + .find(|t| { + let url = t["url"].as_str().unwrap_or(""); + url.contains("/api/users") && url.starts_with("https://") && t["method"] == "POST" + }) + .expect("missing HTTPS POST /api/users"); + assert_eq!(create_https["status_code"], 201); + assert!( + create_https["request_body"] + .as_str() + .is_some_and(|b| b.contains("Charlie")), + "HTTPS create request_body should contain 'Charlie'" + ); + assert!( + create_https["response_body"] + .as_str() + .is_some_and(|b| b.contains("Charlie")), + "HTTPS create response_body should contain 'Charlie'" + ); + + // Cross-cutting checks: every trace must have required fields + for (i, t) in traces.iter().enumerate() { + assert!( + t["trace_id"].as_str().is_some_and(|s| !s.is_empty()), + "trace[{i}] trace_id" + ); + assert!( + t["span_id"].as_str().is_some_and(|s| !s.is_empty()), + "trace[{i}] span_id" + ); + assert!( + t["timestamp_ms"].as_u64().is_some_and(|v| v > 0), + "trace[{i}] timestamp_ms" + ); + assert!( + t["request_headers"].is_object(), + "trace[{i}] request_headers" + ); + assert!( + t["response_headers"].is_object(), + "trace[{i}] response_headers" + ); + } + + eprintln!("All 4 traces (2 HTTP + 2 HTTPS) verified."); +} From 31987830471827b64f34570e1ea3e159fff32378 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 09:22:03 +0000 Subject: [PATCH 04/11] Remove Spring Boot test in favour of java-http-clients approach Spring Boot adds unnecessary framework overhead. The java-http-clients integration test covers the same proxy-capture scenarios using four major Java HTTP client libraries (JDK HttpClient, AsyncHttpClient, Jetty HttpClient, Apache HttpClient 5) without the Spring Boot dependency. https://claude.ai/code/session_01E3FVEjny7BKfgUeAGTM955 --- tests/apps/springboot-app/pom.xml | 43 --- .../example/phantom/ClientApplication.java | 126 ------- tests/proxy_springboot_integration.rs | 354 ------------------ 3 files changed, 523 deletions(-) delete mode 100644 tests/apps/springboot-app/pom.xml delete mode 100644 tests/apps/springboot-app/src/main/java/com/example/phantom/ClientApplication.java delete mode 100644 tests/proxy_springboot_integration.rs diff --git a/tests/apps/springboot-app/pom.xml b/tests/apps/springboot-app/pom.xml deleted file mode 100644 index 1c0df7c..0000000 --- a/tests/apps/springboot-app/pom.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - 4.0.0 - - - org.springframework.boot - spring-boot-starter-parent - 3.2.5 - - - - com.example.phantom - phantom-springboot-client - 0.0.1-SNAPSHOT - jar - Phantom integration test Spring Boot client - - - 17 - 17 - 17 - - - - - - org.springframework.boot - spring-boot-starter - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - - diff --git a/tests/apps/springboot-app/src/main/java/com/example/phantom/ClientApplication.java b/tests/apps/springboot-app/src/main/java/com/example/phantom/ClientApplication.java deleted file mode 100644 index 1ca5934..0000000 --- a/tests/apps/springboot-app/src/main/java/com/example/phantom/ClientApplication.java +++ /dev/null @@ -1,126 +0,0 @@ -package com.example.phantom; - -// Spring Boot CommandLineRunner that makes HTTP and HTTPS requests using the -// JDK java.net.http.HttpClient, explicitly reading the HTTP_PROXY env var set -// by phantom to configure proxy routing. -// -// Environment: -// HTTP_PROXY — set by phantom, e.g. http://127.0.0.1:8080 -// BACKEND_HTTP_URL — e.g. http://127.0.0.1:3000 -// BACKEND_HTTPS_URL — e.g. https://localhost:3443 (optional) - -import org.springframework.boot.CommandLineRunner; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; -import java.net.InetSocketAddress; -import java.net.ProxySelector; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.security.cert.X509Certificate; - -@SpringBootApplication -public class ClientApplication implements CommandLineRunner { - - public static void main(String[] args) { - // SpringApplication.exit propagates the runner's exit code through System.exit, - // ensuring the JVM terminates cleanly after the runner completes. - System.exit(SpringApplication.exit(SpringApplication.run(ClientApplication.class, args))); - } - - @Override - public void run(String... args) throws Exception { - String backendHttpUrl = System.getenv("BACKEND_HTTP_URL"); - String backendHttpsUrl = System.getenv("BACKEND_HTTPS_URL"); - String httpProxy = System.getenv("HTTP_PROXY"); - - if (backendHttpUrl == null || backendHttpUrl.isBlank()) { - throw new IllegalStateException("BACKEND_HTTP_URL env var is required"); - } - if (httpProxy == null || httpProxy.isBlank()) { - throw new IllegalStateException("HTTP_PROXY env var is required (set by phantom)"); - } - - // Parse proxy from HTTP_PROXY env var (set automatically by phantom) - URI proxyUri = URI.create(httpProxy); - ProxySelector proxySelector = ProxySelector.of( - new InetSocketAddress(proxyUri.getHost(), proxyUri.getPort()) - ); - - // Trust-all SSLContext — phantom presents a dynamically-generated MITM CA cert; - // skipping verification is equivalent to NODE_TLS_REJECT_UNAUTHORIZED=0. - // For testing ONLY — never use in production. - SSLContext trustAllCtx = buildTrustAllSslContext(); - - HttpClient client = HttpClient.newBuilder() - .proxy(proxySelector) - .sslContext(trustAllCtx) - .build(); - - // HTTP: GET /api/health - HttpResponse r1 = client.send( - HttpRequest.newBuilder() - .GET() - .uri(URI.create(backendHttpUrl + "/api/health")) - .build(), - HttpResponse.BodyHandlers.ofString() - ); - System.out.println("http health: status=" + r1.statusCode() + " body=" + r1.body()); - - // HTTP: GET /api/users - HttpResponse r2 = client.send( - HttpRequest.newBuilder() - .GET() - .uri(URI.create(backendHttpUrl + "/api/users")) - .build(), - HttpResponse.BodyHandlers.ofString() - ); - System.out.println("http users: status=" + r2.statusCode() + " body=" + r2.body()); - - // HTTPS requests (optional — only when BACKEND_HTTPS_URL is provided) - if (backendHttpsUrl != null && !backendHttpsUrl.isBlank()) { - - // HTTPS: GET /api/health - HttpResponse r3 = client.send( - HttpRequest.newBuilder() - .GET() - .uri(URI.create(backendHttpsUrl + "/api/health")) - .build(), - HttpResponse.BodyHandlers.ofString() - ); - System.out.println("https health: status=" + r3.statusCode() + " body=" + r3.body()); - - // HTTPS: POST /api/users - String postBody = "{\"name\":\"Charlie\",\"email\":\"charlie@example.com\"}"; - HttpResponse r4 = client.send( - HttpRequest.newBuilder() - .POST(HttpRequest.BodyPublishers.ofString(postBody)) - .uri(URI.create(backendHttpsUrl + "/api/users")) - .header("Content-Type", "application/json") - .build(), - HttpResponse.BodyHandlers.ofString() - ); - System.out.println("https create: status=" + r4.statusCode() + " body=" + r4.body()); - } - - System.out.println("CLIENT_DONE"); - } - - private static SSLContext buildTrustAllSslContext() throws Exception { - TrustManager[] trustAllManagers = new TrustManager[]{ - new X509TrustManager() { - public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } - public void checkClientTrusted(X509Certificate[] chain, String authType) {} - public void checkServerTrusted(X509Certificate[] chain, String authType) {} - } - }; - SSLContext ctx = SSLContext.getInstance("TLS"); - ctx.init(null, trustAllManagers, new java.security.SecureRandom()); - return ctx; - } -} diff --git a/tests/proxy_springboot_integration.rs b/tests/proxy_springboot_integration.rs deleted file mode 100644 index 1b1f628..0000000 --- a/tests/proxy_springboot_integration.rs +++ /dev/null @@ -1,354 +0,0 @@ -//! Integration test: phantom proxy transparently traces a Spring Boot app -//! -//! Verifies non-invasive proxy tracing: the Spring Boot CommandLineRunner reads -//! `HTTP_PROXY` (set automatically by phantom) and wires it into -//! `java.net.http.HttpClient`. No application business logic is proxy-aware. -//! -//! Tests both HTTP and HTTPS (MITM) capture. -//! -//! Requirements: `java` (17+) and `mvn` on PATH. -//! Run: `cargo test --test proxy_springboot_integration` - -use std::io::{Read, Write as IoWrite}; -use std::net::{TcpListener, TcpStream}; -use std::path::Path; -use std::process::{Command, Stdio}; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -fn available_port() -> u16 { - TcpListener::bind("127.0.0.1:0") - .expect("bind :0") - .local_addr() - .unwrap() - .port() -} - -fn wait_for_port(port: u16, timeout: Duration) -> bool { - let start = Instant::now(); - while start.elapsed() < timeout { - if TcpStream::connect(format!("127.0.0.1:{port}")).is_ok() { - return true; - } - std::thread::sleep(Duration::from_millis(50)); - } - false -} - -// ───────────────────────────────────────────────────────────────────────────── -// Mock backends -// ───────────────────────────────────────────────────────────────────────────── - -const HEALTH_BODY: &str = r#"{"status":"ok"}"#; -const USERS_BODY: &str = r#"[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]"#; -const CREATED_BODY: &str = r#"{"id":3,"name":"Charlie","email":"charlie@example.com"}"#; - -fn route_request(req: &str) -> (&str, &str) { - let first = req.lines().next().unwrap_or(""); - if first.starts_with("GET") && first.contains("/api/health") { - ("200 OK", HEALTH_BODY) - } else if first.starts_with("GET") && first.contains("/api/users") { - ("200 OK", USERS_BODY) - } else if first.starts_with("POST") && first.contains("/api/users") { - ("201 Created", CREATED_BODY) - } else { - ("404 Not Found", r#"{"error":"Not Found"}"#) - } -} - -fn write_response(stream: &mut impl IoWrite, status: &str, body: &str) { - let resp = format!( - "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ); - let _ = stream.write_all(resp.as_bytes()); - let _ = stream.flush(); -} - -fn handle_stream(stream: &mut (impl Read + IoWrite)) { - let mut buf = [0u8; 8192]; - let n = match stream.read(&mut buf) { - Ok(0) | Err(_) => return, - Ok(n) => n, - }; - let req = String::from_utf8_lossy(&buf[..n]); - let (status, body) = route_request(&req); - write_response(stream, status, body); -} - -fn start_http_backend(port: u16) -> std::thread::JoinHandle<()> { - std::thread::spawn(move || { - let listener = TcpListener::bind(format!("127.0.0.1:{port}")).unwrap(); - listener.set_nonblocking(false).expect("set_nonblocking(false)"); - for stream in listener.incoming() { - match stream { - Ok(mut s) => handle_stream(&mut s), - Err(_) => break, - } - } - }) -} - -fn start_https_backend( - port: u16, - cert_der: Vec, - key_der: Vec, -) -> std::thread::JoinHandle<()> { - std::thread::spawn(move || { - let certs = vec![rustls_pki_types::CertificateDer::from(cert_der)]; - let key = rustls_pki_types::PrivateKeyDer::try_from(key_der).expect("parse private key"); - - let server_config = Arc::new( - rustls::ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(certs, key) - .expect("build ServerConfig"), - ); - - let listener = TcpListener::bind(format!("127.0.0.1:{port}")).unwrap(); - for stream in listener.incoming() { - match stream { - Ok(tcp) => { - let conn = match rustls::ServerConnection::new(server_config.clone()) { - Ok(c) => c, - Err(_) => continue, - }; - let mut tls = rustls::StreamOwned::new(conn, tcp); - handle_stream(&mut tls); - } - Err(_) => break, - } - } - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Test -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn test_proxy_captures_springboot_app_traffic() { - // Pre-flight: require java and mvn - if Command::new("java") - .arg("-version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_err() - { - eprintln!("SKIP: `java` not found"); - return; - } - if Command::new("mvn") - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_err() - { - eprintln!("SKIP: `mvn` not found"); - return; - } - - let phantom_bin = env!("CARGO_BIN_EXE_phantom"); - let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/apps/springboot-app"); - let tmp_dir = tempfile::tempdir().expect("tempdir"); - - // Build the Spring Boot fat JAR - // -DskipTests: no unit tests in the app, avoids needing a test runner. - // --no-transfer-progress: suppress download progress bars in CI logs. - let mvn_status = Command::new("mvn") - .args(["package", "-q", "--no-transfer-progress", "-DskipTests", "-f"]) - .arg(app_dir.join("pom.xml")) - .status() - .expect("mvn package"); - assert!(mvn_status.success(), "mvn package failed"); - - // Locate the fat JAR at the deterministic Maven output path - let jar_path = app_dir - .join("target") - .join("phantom-springboot-client-0.0.1-SNAPSHOT.jar"); - assert!( - jar_path.exists(), - "JAR not found at {}: did mvn package succeed?", - jar_path.display() - ); - - // Allocate ports - let http_port = available_port(); - let https_port = available_port(); - let proxy_port = available_port(); - - // Generate self-signed cert for the HTTPS mock backend - let certified = - rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).expect("generate cert"); - let cert_der = certified.cert.der().to_vec(); - let key_der = certified.key_pair.serialize_der(); - - // Start HTTP backend - let _http_thread = start_http_backend(http_port); - assert!( - wait_for_port(http_port, Duration::from_secs(3)), - "HTTP backend did not start" - ); - - // Start HTTPS backend - let _https_thread = start_https_backend(https_port, cert_der, key_der); - assert!( - wait_for_port(https_port, Duration::from_secs(3)), - "HTTPS backend did not start" - ); - - // Run phantom with `-- java -jar {jar}` - // phantom sets HTTP_PROXY automatically; the Spring Boot app reads it. - // --insecure: phantom's proxy skips cert validation for the self-signed backend cert. - // The Java client's trust-all SSLContext handles phantom's MITM CA cert. - let phantom_output = Command::new(phantom_bin) - .args([ - "--backend", - "proxy", - "--output", - "jsonl", - "--port", - &proxy_port.to_string(), - "--insecure", - "--data-dir", - ]) - .arg(tmp_dir.path()) - .arg("--") - .arg("java") - .arg("-jar") - .arg(&jar_path) - .env("BACKEND_HTTP_URL", format!("http://127.0.0.1:{http_port}")) - .env( - "BACKEND_HTTPS_URL", - format!("https://localhost:{https_port}"), - ) - .output() - .expect("run phantom"); - - let stdout_buf = String::from_utf8_lossy(&phantom_output.stdout).into_owned(); - let stderr_buf = String::from_utf8_lossy(&phantom_output.stderr).into_owned(); - - assert!( - phantom_output.status.success(), - "phantom exited non-zero.\n stdout:\n{stdout_buf}\n stderr:\n{stderr_buf}" - ); - - // Parse JSONL traces - let traces: Vec = stdout_buf - .lines() - .filter(|l| l.starts_with('{')) - .filter_map(|l| serde_json::from_str(l).ok()) - .collect(); - - // Expect 4 traces: 2 HTTP (GET health, GET users) + 2 HTTPS (GET health, POST users) - assert_eq!( - traces.len(), - 4, - "Expected 4 traces (2 HTTP + 2 HTTPS), got {}.\n stdout:\n{stdout_buf}\n stderr:\n{stderr_buf}", - traces.len(), - ); - - // HTTP: GET /api/health - let health_http = traces - .iter() - .find(|t| { - let url = t["url"].as_str().unwrap_or(""); - url.contains("/api/health") && url.starts_with("http://") - }) - .expect("missing HTTP GET /api/health"); - assert_eq!(health_http["method"], "GET"); - assert_eq!(health_http["status_code"], 200); - assert!( - health_http["response_body"] - .as_str() - .is_some_and(|b| b.contains("ok")), - "HTTP health response_body should contain 'ok'" - ); - - // HTTP: GET /api/users - let users_http = traces - .iter() - .find(|t| { - let url = t["url"].as_str().unwrap_or(""); - url.contains("/api/users") && url.starts_with("http://") && t["method"] == "GET" - }) - .expect("missing HTTP GET /api/users"); - assert_eq!(users_http["status_code"], 200); - assert!( - users_http["response_body"] - .as_str() - .is_some_and(|b| b.contains("Alice")), - "HTTP users response_body should contain 'Alice'" - ); - - // HTTPS: GET /api/health - let health_https = traces - .iter() - .find(|t| { - let url = t["url"].as_str().unwrap_or(""); - url.contains("/api/health") && url.starts_with("https://") - }) - .expect("missing HTTPS GET /api/health"); - assert_eq!(health_https["method"], "GET"); - assert_eq!(health_https["status_code"], 200); - assert!( - health_https["response_body"] - .as_str() - .is_some_and(|b| b.contains("ok")), - "HTTPS health response_body should contain 'ok'" - ); - - // HTTPS: POST /api/users - let create_https = traces - .iter() - .find(|t| { - let url = t["url"].as_str().unwrap_or(""); - url.contains("/api/users") && url.starts_with("https://") && t["method"] == "POST" - }) - .expect("missing HTTPS POST /api/users"); - assert_eq!(create_https["status_code"], 201); - assert!( - create_https["request_body"] - .as_str() - .is_some_and(|b| b.contains("Charlie")), - "HTTPS create request_body should contain 'Charlie'" - ); - assert!( - create_https["response_body"] - .as_str() - .is_some_and(|b| b.contains("Charlie")), - "HTTPS create response_body should contain 'Charlie'" - ); - - // Cross-cutting checks: every trace must have required fields - for (i, t) in traces.iter().enumerate() { - assert!( - t["trace_id"].as_str().is_some_and(|s| !s.is_empty()), - "trace[{i}] trace_id" - ); - assert!( - t["span_id"].as_str().is_some_and(|s| !s.is_empty()), - "trace[{i}] span_id" - ); - assert!( - t["timestamp_ms"].as_u64().is_some_and(|v| v > 0), - "trace[{i}] timestamp_ms" - ); - assert!( - t["request_headers"].is_object(), - "trace[{i}] request_headers" - ); - assert!( - t["response_headers"].is_object(), - "trace[{i}] response_headers" - ); - } - - eprintln!("All 4 traces (2 HTTP + 2 HTTPS) verified."); -} From eeb81393744074cdab5db8e446e54b3fd537d1be Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 09:56:07 +0000 Subject: [PATCH 05/11] Apply cargo fmt formatting https://claude.ai/code/session_01E3FVEjny7BKfgUeAGTM955 --- tests/proxy_java_clients_integration.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/proxy_java_clients_integration.rs b/tests/proxy_java_clients_integration.rs index f342f01..9fef410 100644 --- a/tests/proxy_java_clients_integration.rs +++ b/tests/proxy_java_clients_integration.rs @@ -89,7 +89,9 @@ fn handle_stream(stream: &mut (impl Read + IoWrite)) { fn start_http_backend(port: u16) -> std::thread::JoinHandle<()> { std::thread::spawn(move || { let listener = TcpListener::bind(format!("127.0.0.1:{port}")).unwrap(); - listener.set_nonblocking(false).expect("set_nonblocking(false)"); + listener + .set_nonblocking(false) + .expect("set_nonblocking(false)"); for stream in listener.incoming() { match stream { Ok(mut s) => handle_stream(&mut s), @@ -291,9 +293,7 @@ fn test_proxy_captures_java_http_clients() { assert_eq!(t["method"], "GET", "{client} {scheme} method"); assert_eq!(t["status_code"], 200, "{client} {scheme} status_code"); assert!( - t["url"] - .as_str() - .is_some_and(|u| u.contains("/api/health")), + t["url"].as_str().is_some_and(|u| u.contains("/api/health")), "{client} {scheme} url should contain /api/health" ); assert!( From 10b1dcdba1dba7afb04452e23b6e0f95ba7b167f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 10:06:45 +0000 Subject: [PATCH 06/11] Fix java-http-clients build and test - Use wagon transport for Maven 3.9+ so settings.xml proxy auth works - Skip mvn package if JAR already built (faster re-runs) - Fix Java compile errors: add ProxyServer import, remove setSslContext from AsyncHttpClient (use setUseInsecureTrustManager only), replace TrustAllStrategy.INSTANCE with lambda for HttpClient 5 compatibility https://claude.ai/code/session_01E3FVEjny7BKfgUeAGTM955 --- .../main/java/com/example/phantom/Client.java | 5 ++-- tests/proxy_java_clients_integration.rs | 25 ++++++++++++------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/apps/java-http-clients/src/main/java/com/example/phantom/Client.java b/tests/apps/java-http-clients/src/main/java/com/example/phantom/Client.java index 5904457..e896620 100644 --- a/tests/apps/java-http-clients/src/main/java/com/example/phantom/Client.java +++ b/tests/apps/java-http-clients/src/main/java/com/example/phantom/Client.java @@ -13,6 +13,7 @@ // Each client adds an x-phantom-client header to identify itself in traces. import org.asynchttpclient.*; +import org.asynchttpclient.proxy.ProxyServer; import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.HttpProxy; import org.eclipse.jetty.client.transport.HttpClientTransportOverHTTP; @@ -26,7 +27,6 @@ import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.io.entity.EntityUtils; import org.apache.hc.core5.ssl.SSLContextBuilder; -import org.apache.hc.core5.ssl.TrustAllStrategy; import javax.net.ssl.*; import java.net.InetSocketAddress; @@ -104,7 +104,6 @@ static void runAsyncHttpClient(String httpBase, String httpsBase) throws Excepti InetSocketAddress proxy = proxyAddress(); DefaultAsyncHttpClientConfig.Builder cfgBuilder = new DefaultAsyncHttpClientConfig.Builder() - .setSslContext(trustAllSslContext()) .setUseInsecureTrustManager(true); if (proxy != null) { cfgBuilder.setProxyServer(new ProxyServer.Builder(proxy.getHostName(), proxy.getPort()).build()); @@ -175,7 +174,7 @@ static void runApacheHttpClient(String httpBase, String httpsBase) throws Except InetSocketAddress proxy = proxyAddress(); SSLContext sslCtx = SSLContextBuilder.create() - .loadTrustMaterial(TrustAllStrategy.INSTANCE) + .loadTrustMaterial((chain, authType) -> true) .build(); var sslSocketFactory = SSLConnectionSocketFactoryBuilder.create() diff --git a/tests/proxy_java_clients_integration.rs b/tests/proxy_java_clients_integration.rs index 9fef410..122b946 100644 --- a/tests/proxy_java_clients_integration.rs +++ b/tests/proxy_java_clients_integration.rs @@ -180,16 +180,23 @@ fn test_proxy_captures_java_http_clients() { let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/apps/java-http-clients"); let tmp_dir = tempfile::tempdir().expect("tempdir"); - // ── Build the fat JAR ───────────────────────────────────────────────── - let mvn_status = Command::new("mvn") - .args(["package", "-q", "--no-transfer-progress", "-f"]) - .arg(app_dir.join("pom.xml")) - .status() - .expect("mvn package"); - assert!(mvn_status.success(), "mvn package failed"); - + // ── Build the fat JAR (skip if already built) ───────────────────────── let jar_path = app_dir.join("target/java-http-clients-0.0.1-SNAPSHOT.jar"); - assert!(jar_path.exists(), "JAR not found at {jar_path:?}"); + if !jar_path.exists() { + let mvn_status = Command::new("mvn") + .args([ + "package", + "-q", + "--no-transfer-progress", + "-Dmaven.resolver.transport=wagon", + "-f", + ]) + .arg(app_dir.join("pom.xml")) + .status() + .expect("mvn package"); + assert!(mvn_status.success(), "mvn package failed"); + assert!(jar_path.exists(), "JAR not found at {jar_path:?}"); + } let http_port = available_port(); let https_port = available_port(); From 7600e73eb61ab41bc3b21f72169c0d5f1176a176 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 10:07:24 +0000 Subject: [PATCH 07/11] Ignore Maven target/ directories https://claude.ai/code/session_01E3FVEjny7BKfgUeAGTM955 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index bb3b816..e96aded 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +**/target/** **/node_modules/** .idea/ From 2c357542919094c2e4dd7f53a8f3386a46487519 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 13:00:57 +0000 Subject: [PATCH 08/11] Transparently inject Java proxy via JAVA_TOOL_OPTIONS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of requiring the Java test app to read HTTP_PROXY and configure each HTTP client library explicitly, phantom now injects the proxy settings transparently via JAVA_TOOL_OPTIONS when the child command is a java/javaw executable. Changes: - src/main.rs: add is_java_command() detection; when spawning a Java process, append -Dhttp.proxyHost, -Dhttps.proxyHost, -Dhttp.proxyPort, -Dhttps.proxyPort and -Dhttp.nonProxyHosts= to JAVA_TOOL_OPTIONS so any JVM application is proxied without app-level changes - Client.java: remove proxyAddress() and all explicit proxy setup; remove AsyncHttpClient and Jetty (they bypass JVM ProxySelector); keep JDK HttpClient (uses ProxySelector.getDefault() automatically) and Apache HttpClient 5 with SystemDefaultRoutePlanner - pom.xml: remove async-http-client, jetty-client, slf4j-nop deps - proxy_java_clients_integration.rs: update to 2 clients × 2 schemes = 4 traces; update comment to reflect transparent tracing concept https://claude.ai/code/session_01E3FVEjny7BKfgUeAGTM955 --- src/main.rs | 31 +++- tests/apps/java-http-clients/pom.xml | 21 --- .../main/java/com/example/phantom/Client.java | 150 ++++-------------- tests/proxy_java_clients_integration.rs | 29 ++-- 4 files changed, 74 insertions(+), 157 deletions(-) diff --git a/src/main.rs b/src/main.rs index 7ec1c75..2d9a5bc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -372,6 +372,15 @@ fn is_node_command(exe: &str) -> bool { base == "node" || base == "nodejs" } +/// Returns `true` if `exe` (path or bare name) resolves to `java` or `javaw`. +fn is_java_command(exe: &str) -> bool { + let base = Path::new(exe) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(exe); + base == "java" || base == "javaw" +} + /// Spawns `command` as a child process routed through the phantom proxy. /// /// * `HTTP_PROXY` / `http_proxy` are set so plain HTTP is captured. @@ -407,10 +416,26 @@ fn spawn_proxy_child( (command[1..].to_vec(), None) }; - let child = std::process::Command::new(exe) - .args(&actual_args) + let mut cmd = std::process::Command::new(exe); + cmd.args(&actual_args) .env("HTTP_PROXY", &proxy_url) - .env("http_proxy", &proxy_url) + .env("http_proxy", &proxy_url); + + // For Java processes, inject proxy settings via JAVA_TOOL_OPTIONS so any + // JVM application is traced transparently — no app-level proxy code needed. + // We append to any existing JAVA_TOOL_OPTIONS (e.g. set by the CI environment) + // so the phantom proxy settings take effect last (last -D wins in JVM args). + // -Dhttp.nonProxyHosts= clears the exclusion list (which often includes + // localhost/127.0.0.1 in CI) so local test backends are also proxied. + if is_java_command(exe) { + let proxy_jvm_opts = format!( + " -Dhttp.proxyHost=127.0.0.1 -Dhttp.proxyPort={proxy_port} -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort={proxy_port} -Dhttp.nonProxyHosts= -Dhttps.nonProxyHosts=" + ); + let existing = std::env::var("JAVA_TOOL_OPTIONS").unwrap_or_default(); + cmd.env("JAVA_TOOL_OPTIONS", format!("{existing}{proxy_jvm_opts}")); + } + + let child = cmd .spawn() .map_err(|e| anyhow::anyhow!("failed to spawn {:?}: {e}", exe))?; diff --git a/tests/apps/java-http-clients/pom.xml b/tests/apps/java-http-clients/pom.xml index c555519..1926e1c 100644 --- a/tests/apps/java-http-clients/pom.xml +++ b/tests/apps/java-http-clients/pom.xml @@ -17,33 +17,12 @@ - - - org.asynchttpclient - async-http-client - 3.0.1 - - - - - org.eclipse.jetty - jetty-client - 12.0.14 - - org.apache.httpcomponents.client5 httpclient5 5.3.1 - - - - org.slf4j - slf4j-nop - 2.0.13 - diff --git a/tests/apps/java-http-clients/src/main/java/com/example/phantom/Client.java b/tests/apps/java-http-clients/src/main/java/com/example/phantom/Client.java index e896620..a453410 100644 --- a/tests/apps/java-http-clients/src/main/java/com/example/phantom/Client.java +++ b/tests/apps/java-http-clients/src/main/java/com/example/phantom/Client.java @@ -1,35 +1,37 @@ package com.example.phantom; -// A plain Java CLI application that makes HTTP and HTTPS requests using four -// different HTTP client libraries. This file contains ZERO proxy configuration -// — the proxy is configured based on the HTTP_PROXY environment variable that -// phantom sets automatically when running: phantom -- java -jar client.jar +// A plain Java CLI application that makes HTTP and HTTPS requests using two +// Java HTTP client libraries that honour JVM system proxy settings. +// +// This file contains ZERO proxy configuration. Phantom injects the proxy +// transparently via JAVA_TOOL_OPTIONS when running: +// +// phantom -- java -jar client.jar +// +// Phantom sets -Dhttp.proxyHost / -Dhttps.proxyHost etc. so both clients +// pick up the proxy automatically through ProxySelector.getDefault(). +// +// The trust-all SSLContext is required because phantom performs MITM for +// HTTPS — it presents its own dynamically-generated certificate. This is +// the equivalent of NODE_TLS_REJECT_UNAUTHORIZED=0 in the Node.js tests. // // Environment: -// HTTP_PROXY — set by phantom, e.g. http://127.0.0.1:8080 // BACKEND_HTTP_URL — e.g. http://127.0.0.1:3000 // BACKEND_HTTPS_URL — e.g. https://localhost:3443 (optional) // -// Each client adds an x-phantom-client header to identify itself in traces. - -import org.asynchttpclient.*; -import org.asynchttpclient.proxy.ProxyServer; -import org.eclipse.jetty.client.HttpClient; -import org.eclipse.jetty.client.HttpProxy; -import org.eclipse.jetty.client.transport.HttpClientTransportOverHTTP; -import org.eclipse.jetty.util.ssl.SslContextFactory; +// Each client adds an x-phantom-client header so traces can be identified. + import org.apache.hc.client5.http.classic.methods.HttpGet; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.impl.routing.SystemDefaultRoutePlanner; import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder; -import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.io.entity.EntityUtils; import org.apache.hc.core5.ssl.SSLContextBuilder; import javax.net.ssl.*; -import java.net.InetSocketAddress; import java.net.ProxySelector; import java.net.URI; import java.net.http.HttpRequest; @@ -43,15 +45,6 @@ public class Client { // Shared helpers // ------------------------------------------------------------------------- - /** Parse HTTP_PROXY env var → InetSocketAddress, or null if not set. */ - static InetSocketAddress proxyAddress() { - String raw = System.getenv("HTTP_PROXY"); - if (raw == null) raw = System.getenv("http_proxy"); - if (raw == null) return null; - URI u = URI.create(raw); - return new InetSocketAddress(u.getHost(), u.getPort()); - } - /** SSLContext that trusts any certificate (for MITM testing). */ static SSLContext trustAllSslContext() throws Exception { TrustManager[] trustAll = {new X509TrustManager() { @@ -69,13 +62,11 @@ public void checkServerTrusted(X509Certificate[] c, String a) {} // ------------------------------------------------------------------------- static void runJdkHttpClient(String httpBase, String httpsBase) throws Exception { - InetSocketAddress proxy = proxyAddress(); - java.net.http.HttpClient.Builder builder = java.net.http.HttpClient.newBuilder() - .sslContext(trustAllSslContext()); - if (proxy != null) { - builder.proxy(ProxySelector.of(proxy)); - } - java.net.http.HttpClient client = builder.build(); + // No explicit proxy — automatically uses ProxySelector.getDefault() + // which reads -Dhttp.proxyHost / -Dhttps.proxyHost injected by phantom. + java.net.http.HttpClient client = java.net.http.HttpClient.newBuilder() + .sslContext(trustAllSslContext()) + .build(); // HTTP HttpRequest httpReq = HttpRequest.newBuilder() @@ -97,88 +88,14 @@ static void runJdkHttpClient(String httpBase, String httpsBase) throws Exception } // ------------------------------------------------------------------------- - // 2. AsyncHttpClient (Netty-based) - // ------------------------------------------------------------------------- - - static void runAsyncHttpClient(String httpBase, String httpsBase) throws Exception { - InetSocketAddress proxy = proxyAddress(); - DefaultAsyncHttpClientConfig.Builder cfgBuilder = - new DefaultAsyncHttpClientConfig.Builder() - .setUseInsecureTrustManager(true); - if (proxy != null) { - cfgBuilder.setProxyServer(new ProxyServer.Builder(proxy.getHostName(), proxy.getPort()).build()); - } - - try (AsyncHttpClient client = new DefaultAsyncHttpClient(cfgBuilder.build())) { - // HTTP - Response r1 = client.prepareGet(httpBase + "/api/health") - .addHeader("x-phantom-client", "async-http-client") - .execute().get(); - System.out.println("async http: status=" + r1.getStatusCode() + " body=" + r1.getResponseBody()); - - // HTTPS - if (httpsBase != null) { - Response r2 = client.prepareGet(httpsBase + "/api/health") - .addHeader("x-phantom-client", "async-http-client") - .execute().get(); - System.out.println("async https: status=" + r2.getStatusCode() + " body=" + r2.getResponseBody()); - } - } - } - - // ------------------------------------------------------------------------- - // 3. Jetty HttpClient - // ------------------------------------------------------------------------- - - static void runJettyHttpClient(String httpBase, String httpsBase) throws Exception { - InetSocketAddress proxy = proxyAddress(); - - SslContextFactory.Client sslFactory = new SslContextFactory.Client(true); - sslFactory.setSslContext(trustAllSslContext()); - - HttpClient client = new HttpClient(new HttpClientTransportOverHTTP()); - client.setSslContextFactory(sslFactory); - - if (proxy != null) { - client.getProxyConfiguration().addProxy( - new HttpProxy(proxy.getHostName(), proxy.getPort())); - } - - client.start(); - try { - // HTTP - org.eclipse.jetty.client.ContentResponse r1 = client.newRequest(httpBase + "/api/health") - .method(org.eclipse.jetty.http.HttpMethod.GET) - .headers(h -> h.add("x-phantom-client", "jetty-httpclient")) - .send(); - System.out.println("jetty http: status=" + r1.getStatus() + " body=" + r1.getContentAsString()); - - // HTTPS - if (httpsBase != null) { - org.eclipse.jetty.client.ContentResponse r2 = client.newRequest(httpsBase + "/api/health") - .method(org.eclipse.jetty.http.HttpMethod.GET) - .headers(h -> h.add("x-phantom-client", "jetty-httpclient")) - .send(); - System.out.println("jetty https: status=" + r2.getStatus() + " body=" + r2.getContentAsString()); - } - } finally { - client.stop(); - } - } - - // ------------------------------------------------------------------------- - // 4. Apache HttpClient 5 + // 2. Apache HttpClient 5 // ------------------------------------------------------------------------- static void runApacheHttpClient(String httpBase, String httpsBase) throws Exception { - InetSocketAddress proxy = proxyAddress(); - - SSLContext sslCtx = SSLContextBuilder.create() - .loadTrustMaterial((chain, authType) -> true) - .build(); - var sslSocketFactory = SSLConnectionSocketFactoryBuilder.create() - .setSslContext(sslCtx) + .setSslContext(SSLContextBuilder.create() + .loadTrustMaterial((chain, authType) -> true) + .build()) .setHostnameVerifier(new NoopHostnameVerifier()) .build(); @@ -186,14 +103,15 @@ static void runApacheHttpClient(String httpBase, String httpsBase) throws Except .setSSLSocketFactory(sslSocketFactory) .build(); - var clientBuilder = HttpClients.custom() - .setConnectionManager(connManager); + // SystemDefaultRoutePlanner reads ProxySelector.getDefault(), which + // respects -Dhttp.proxyHost / -Dhttps.proxyHost injected by phantom. + var routePlanner = new SystemDefaultRoutePlanner(ProxySelector.getDefault()); - if (proxy != null) { - clientBuilder.setProxy(new HttpHost(proxy.getHostName(), proxy.getPort())); - } + try (CloseableHttpClient client = HttpClients.custom() + .setConnectionManager(connManager) + .setRoutePlanner(routePlanner) + .build()) { - try (CloseableHttpClient client = clientBuilder.build()) { // HTTP HttpGet get1 = new HttpGet(httpBase + "/api/health"); get1.addHeader("x-phantom-client", "apache-httpclient"); @@ -226,8 +144,6 @@ public static void main(String[] args) throws Exception { } runJdkHttpClient(httpBase, httpsBase); - runAsyncHttpClient(httpBase, httpsBase); - runJettyHttpClient(httpBase, httpsBase); runApacheHttpClient(httpBase, httpsBase); System.out.println("CLIENT_DONE"); diff --git a/tests/proxy_java_clients_integration.rs b/tests/proxy_java_clients_integration.rs index 122b946..8585aad 100644 --- a/tests/proxy_java_clients_integration.rs +++ b/tests/proxy_java_clients_integration.rs @@ -1,12 +1,14 @@ //! Integration test: phantom proxy transparently traces Java HTTP client libraries //! //! Verifies that phantom's proxy backend captures HTTP and HTTPS traffic from -//! four major Java HTTP client libraries: +//! Java HTTP client libraries that honour JVM system proxy settings: //! //! 1. JDK java.net.http.HttpClient (built-in, Java 11+) -//! 2. AsyncHttpClient (Netty-based) -//! 3. Jetty HttpClient -//! 4. Apache HttpClient 5 +//! 2. Apache HttpClient 5 +//! +//! Phantom injects the proxy transparently via JAVA_TOOL_OPTIONS — the Java +//! application itself contains zero proxy configuration code. This mirrors +//! how phantom injects proxy-preload.js for Node.js applications. //! //! Each client adds an `x-phantom-client` request header so traces can be //! identified in the JSONL output. The pattern mirrors the Node.js @@ -222,8 +224,8 @@ fn test_proxy_captures_java_http_clients() { ); // ── Run phantom with `-- java -jar client.jar` ──────────────────────── - // phantom sets HTTP_PROXY automatically; the Java app reads it to - // configure each client's proxy selector. + // phantom injects -Dhttp.proxyHost / -Dhttps.proxyHost via JAVA_TOOL_OPTIONS + // so the Java app needs zero proxy configuration of its own. let phantom_output = Command::new(phantom_bin) .args([ "--backend", @@ -263,22 +265,17 @@ fn test_proxy_captures_java_http_clients() { .filter_map(|l| serde_json::from_str(l).ok()) .collect(); - // 4 clients × 2 schemes (HTTP + HTTPS) = 8 traces + // 2 clients × 2 schemes (HTTP + HTTPS) = 4 traces assert_eq!( traces.len(), - 8, - "Expected 8 traces (4 clients × 2 schemes), got {}.\ + 4, + "Expected 4 traces (2 clients × 2 schemes), got {}.\ \n stdout:\n{stdout_buf}\n stderr:\n{stderr_buf}", traces.len(), ); // ── Per-client assertions ───────────────────────────────────────────── - let clients = [ - "jdk-httpclient", - "async-http-client", - "jetty-httpclient", - "apache-httpclient", - ]; + let clients = ["jdk-httpclient", "apache-httpclient"]; for client in clients { for scheme in ["http", "https"] { @@ -337,7 +334,7 @@ fn test_proxy_captures_java_http_clients() { } eprintln!( - "All 8 traces verified. clients: {:?}", + "All 4 traces verified. clients: {:?}", traces.iter().map(client_of).collect::>() ); } From 36b7fdc8e2fdc10e77b637a036ba6dde1feed4c9 Mon Sep 17 00:00:00 2001 From: epli2 Date: Tue, 10 Mar 2026 01:09:04 +0900 Subject: [PATCH 09/11] feat(java): implement Java Agent for transparent HTTP/HTTPS capture - Introduce Phantom Java Agent to force global ProxySelector and bypass SSL verification (trust-all). - Automatically inject -javaagent via JAVA_TOOL_OPTIONS when spawning Java processes in Rust. - Extend Java integration tests to cover 5 major clients: JDK HttpClient, Apache HC 5, OkHttp, Netty, and Jetty. - Update documentation with Java usage instructions. - Update .gitignore to exclude Java build artifacts. --- .gitignore | 8 + .../src/com/example/phantom/Agent.java | 61 ++++++ docs/how-to-use.ja.md | 35 +++- src/main.rs | 36 +++- tests/apps/java-http-clients/pom.xml | 28 +++ .../main/java/com/example/phantom/Client.java | 194 ++++++++---------- tests/proxy_java_clients_integration.rs | 17 +- 7 files changed, 245 insertions(+), 134 deletions(-) create mode 100644 crates/phantom-java-agent/src/com/example/phantom/Agent.java diff --git a/.gitignore b/.gitignore index e96aded..5e4f618 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,11 @@ **/node_modules/** .idea/ +# Java artifacts +*.class +*.jar +!crates/phantom-java-agent/src/**/*.java +!tests/apps/java-http-clients/src/**/*.java +**/out/ +manifest.txt + diff --git a/crates/phantom-java-agent/src/com/example/phantom/Agent.java b/crates/phantom-java-agent/src/com/example/phantom/Agent.java new file mode 100644 index 0000000..26c10fb --- /dev/null +++ b/crates/phantom-java-agent/src/com/example/phantom/Agent.java @@ -0,0 +1,61 @@ +package com.example.phantom; + +import java.lang.instrument.Instrumentation; +import java.net.*; +import java.util.Collections; +import java.util.List; +import java.io.IOException; +import javax.net.ssl.*; +import java.security.SecureRandom; +import java.security.Security; +import java.security.cert.X509Certificate; + +public class Agent { + public static void premain(String agentArgs, Instrumentation inst) { + String proxyHost = System.getProperty("http.proxyHost", "127.0.0.1"); + int proxyPort = Integer.getInteger("http.proxyPort", 8080); + + System.err.println("phantom-agent: Initializing Java Agent..."); + System.err.println("phantom-agent: Forcing proxy -> " + proxyHost + ":" + proxyPort); + + // 1. Force global ProxySelector + final Proxy phantomProxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)); + ProxySelector.setDefault(new ProxySelector() { + @Override + public List select(URI uri) { + return Collections.singletonList(phantomProxy); + } + @Override + public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {} + }); + + System.setProperty("http.nonProxyHosts", ""); + System.setProperty("https.nonProxyHosts", ""); + + // 2. Disable SSL Verification (Trust All) + try { + TrustManager[] trustAllCerts = new TrustManager[]{ + new X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } + public void checkClientTrusted(X509Certificate[] certs, String authType) {} + public void checkServerTrusted(X509Certificate[] certs, String authType) {} + } + }; + + SSLContext sc = SSLContext.getInstance("TLS"); + sc.init(null, trustAllCerts, new SecureRandom()); + SSLContext.setDefault(sc); + HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory()); + HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true); + + // For Netty/Jetty/etc. - Try to influence the default TrustManager + // Note: This is a bit of a hack without bytecode manipulation + System.setProperty("com.sun.net.ssl.checkRevocation", "false"); + System.setProperty("jdk.tls.allowUnsafeServerCertificates", "true"); + + System.err.println("phantom-agent: SSL verification disabled (trust-all)."); + } catch (Exception e) { + System.err.println("phantom-agent: Failed to disable SSL verification: " + e.getMessage()); + } + } +} diff --git a/docs/how-to-use.ja.md b/docs/how-to-use.ja.md index f2d66c9..a717708 100644 --- a/docs/how-to-use.ja.md +++ b/docs/how-to-use.ja.md @@ -37,18 +37,25 @@ Phantom は **ゼロ計装の HTTP/HTTPS 観測ツール**です。アプリケ ## ビルド -**前提条件**: Rust 1.75 以降(stable) +**前提条件**: +- Rust 1.75 以降(stable) +- **(Java 連携用)**: JDK 11 以降 ```bash # リポジトリを取得 git clone cd phantom -# ビルド +# 本体 (Rust) のビルド cargo build --release -# バイナリは target/release/phantom に生成されます。 -# パスを通すか、以下の例では `phantom` コマンドとして説明します。 +# Java Agent のビルド (Java アプリを追跡する場合に必要) +# ※ 詳細は crates/phantom-java-agent 参照 +cd crates/phantom-java-agent +javac -d out src/com/example/phantom/Agent.java +echo "Premain-Class: com.example.phantom.Agent" > manifest.txt +jar cvfm phantom-java-agent.jar manifest.txt -C out . +cd ../.. ``` --- @@ -57,14 +64,18 @@ cargo build --release **30 秒で体験:** -### 1. Node.js アプリをトレース (HTTP/HTTPS 両対応) -Node.js の場合、Phantom は自動的に `proxy-preload.js` を注入するため、アプリ側でプロキシ設定を意識する必要はありません。 - +### 1. Node.js アプリをトレース ```bash phantom -- node app.js ``` -### 2. 一般的なコマンドをトレース (HTTP のみ) +### 2. Java アプリをトレース (HTTP/HTTPS 両対応) +Phantom は自動的に Java Agent を注入し、プロキシ設定と SSL 検証の無効化(MITM 対応)を強制します。 +```bash +phantom -- java -jar my-app.jar +``` + +### 3. 一般的なコマンドをトレース (HTTP のみ) ```bash phantom -- curl http://httpbin.org/get ``` @@ -85,6 +96,14 @@ MITM(中間者)プロキシとして動作します。クロスプラット #### Node.js の自動連携 `phantom -- node app.js` のように実行すると、Phantom は `--require` 引数を用いて透過的にプロキシ設定を注入します。これにより、**axios, undici, fetch() などを用いた HTTPS 通信もコード変更なしでキャプチャ可能**です。 +#### Java の自動連携 +`phantom -- java ...` のように実行すると、Phantom は環境変数 `JAVA_TOOL_OPTIONS` を介して **Phantom Java Agent** を注入します。 + +- **SSL 検証の自動回避**: Phantom が生成する自己署名証明書を自動的に信頼させるため、`SSLHandshakeException` を回避できます。 +- **プロキシの強制適用**: アプリ側でプロキシ設定が書かれていなくても、通信を強制的に Phantom へ誘導します。 +- **対応ライブラリ**: JDK 標準の `HttpClient`、`Apache HttpClient`、`OkHttp` など。 + - ※ `Netty` や `Jetty` など独自のネットワークスタックを持つライブラリは、ライブラリ側の設定で「システムプロキシを使用する」オプションを有効にしてください。 + #### その他のアプリケーション 環境変数 `HTTP_PROXY` を自動設定します。 ```bash diff --git a/src/main.rs b/src/main.rs index 2d9a5bc..e21cfd6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -381,12 +381,30 @@ fn is_java_command(exe: &str) -> bool { base == "java" || base == "javaw" } +/// Returns the path to the phantom Java Agent JAR if it exists in the build output. +fn find_java_agent_jar() -> Option { + // Check common build locations relative to the executable. + let exe_dir = std::env::current_exe().ok()?.parent()?.to_path_buf(); + + let candidates = [ + // Development: crates/phantom-java-agent/phantom-java-agent.jar + PathBuf::from("crates/phantom-java-agent/phantom-java-agent.jar"), + // Relative to binary in target/debug/ or target/release/ + exe_dir.join("../../../crates/phantom-java-agent/phantom-java-agent.jar"), + exe_dir.join("phantom-java-agent.jar"), + ]; + + candidates.into_iter().find(|path| path.exists()) +} + /// Spawns `command` as a child process routed through the phantom proxy. /// /// * `HTTP_PROXY` / `http_proxy` are set so plain HTTP is captured. /// * For Node.js executables the embedded proxy-preload script is written to a /// temp file and prepended as `--require ` so HTTPS is also captured /// without touching the application source. +/// * For Java executables, the phantom-java-agent.jar is injected via -javaagent +/// to force proxy settings and bypass SSL verification globally. /// /// Returns `(child, Option)`. The `TempScript` must be kept alive /// until after the child exits so the file is not deleted prematurely. @@ -421,18 +439,20 @@ fn spawn_proxy_child( .env("HTTP_PROXY", &proxy_url) .env("http_proxy", &proxy_url); - // For Java processes, inject proxy settings via JAVA_TOOL_OPTIONS so any - // JVM application is traced transparently — no app-level proxy code needed. - // We append to any existing JAVA_TOOL_OPTIONS (e.g. set by the CI environment) - // so the phantom proxy settings take effect last (last -D wins in JVM args). - // -Dhttp.nonProxyHosts= clears the exclusion list (which often includes - // localhost/127.0.0.1 in CI) so local test backends are also proxied. + // For Java processes, inject proxy settings and the Java Agent via JAVA_TOOL_OPTIONS. if is_java_command(exe) { - let proxy_jvm_opts = format!( + let mut jvm_opts = format!( " -Dhttp.proxyHost=127.0.0.1 -Dhttp.proxyPort={proxy_port} -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort={proxy_port} -Dhttp.nonProxyHosts= -Dhttps.nonProxyHosts=" ); + + // Inject Java Agent if found to force proxy and bypass SSL verification. + if let Some(agent_path) = find_java_agent_jar() { + let agent_arg = format!(" -javaagent:{}", agent_path.display()); + jvm_opts.push_str(&agent_arg); + } + let existing = std::env::var("JAVA_TOOL_OPTIONS").unwrap_or_default(); - cmd.env("JAVA_TOOL_OPTIONS", format!("{existing}{proxy_jvm_opts}")); + cmd.env("JAVA_TOOL_OPTIONS", format!("{existing}{jvm_opts}")); } let child = cmd diff --git a/tests/apps/java-http-clients/pom.xml b/tests/apps/java-http-clients/pom.xml index 1926e1c..c0c4f7b 100644 --- a/tests/apps/java-http-clients/pom.xml +++ b/tests/apps/java-http-clients/pom.xml @@ -23,6 +23,34 @@ httpclient5 5.3.1 + + + + io.projectreactor.netty + reactor-netty-http + 1.1.20 + + + + + org.eclipse.jetty + jetty-client + 11.0.21 + + + + + com.squareup.okhttp3 + okhttp + 4.12.0 + + + + + org.slf4j + slf4j-simple + 2.0.13 + diff --git a/tests/apps/java-http-clients/src/main/java/com/example/phantom/Client.java b/tests/apps/java-http-clients/src/main/java/com/example/phantom/Client.java index a453410..cc15a69 100644 --- a/tests/apps/java-http-clients/src/main/java/com/example/phantom/Client.java +++ b/tests/apps/java-http-clients/src/main/java/com/example/phantom/Client.java @@ -1,151 +1,117 @@ package com.example.phantom; -// A plain Java CLI application that makes HTTP and HTTPS requests using two -// Java HTTP client libraries that honour JVM system proxy settings. -// -// This file contains ZERO proxy configuration. Phantom injects the proxy -// transparently via JAVA_TOOL_OPTIONS when running: -// -// phantom -- java -jar client.jar -// -// Phantom sets -Dhttp.proxyHost / -Dhttps.proxyHost etc. so both clients -// pick up the proxy automatically through ProxySelector.getDefault(). -// -// The trust-all SSLContext is required because phantom performs MITM for -// HTTPS — it presents its own dynamically-generated certificate. This is -// the equivalent of NODE_TLS_REJECT_UNAUTHORIZED=0 in the Node.js tests. -// -// Environment: -// BACKEND_HTTP_URL — e.g. http://127.0.0.1:3000 -// BACKEND_HTTPS_URL — e.g. https://localhost:3443 (optional) -// -// Each client adds an x-phantom-client header so traces can be identified. +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import org.apache.hc.client5.http.classic.methods.HttpGet; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; -import org.apache.hc.client5.http.impl.routing.SystemDefaultRoutePlanner; -import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; -import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder; import org.apache.hc.core5.http.io.entity.EntityUtils; -import org.apache.hc.core5.ssl.SSLContextBuilder; + +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; + +import org.eclipse.jetty.client.HttpProxy; import javax.net.ssl.*; -import java.net.ProxySelector; -import java.net.URI; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.security.SecureRandom; import java.security.cert.X509Certificate; public class Client { - // ------------------------------------------------------------------------- - // Shared helpers - // ------------------------------------------------------------------------- - - /** SSLContext that trusts any certificate (for MITM testing). */ - static SSLContext trustAllSslContext() throws Exception { - TrustManager[] trustAll = {new X509TrustManager() { - public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } - public void checkClientTrusted(X509Certificate[] c, String a) {} - public void checkServerTrusted(X509Certificate[] c, String a) {} - }}; - SSLContext ctx = SSLContext.getInstance("TLS"); - ctx.init(null, trustAll, new SecureRandom()); - return ctx; - } - - // ------------------------------------------------------------------------- - // 1. JDK java.net.http.HttpClient - // ------------------------------------------------------------------------- - static void runJdkHttpClient(String httpBase, String httpsBase) throws Exception { - // No explicit proxy — automatically uses ProxySelector.getDefault() - // which reads -Dhttp.proxyHost / -Dhttps.proxyHost injected by phantom. - java.net.http.HttpClient client = java.net.http.HttpClient.newBuilder() - .sslContext(trustAllSslContext()) - .build(); - - // HTTP - HttpRequest httpReq = HttpRequest.newBuilder() - .uri(URI.create(httpBase + "/api/health")) - .header("x-phantom-client", "jdk-httpclient") - .GET().build(); - HttpResponse r1 = client.send(httpReq, HttpResponse.BodyHandlers.ofString()); - System.out.println("jdk http: status=" + r1.statusCode() + " body=" + r1.body()); - - // HTTPS + HttpClient client = HttpClient.newBuilder().build(); + client.send(HttpRequest.newBuilder().uri(URI.create(httpBase + "/api/health")).header("x-phantom-client", "jdk-httpclient").GET().build(), HttpResponse.BodyHandlers.ofString()); if (httpsBase != null) { - HttpRequest httpsReq = HttpRequest.newBuilder() - .uri(URI.create(httpsBase + "/api/health")) - .header("x-phantom-client", "jdk-httpclient") - .GET().build(); - HttpResponse r2 = client.send(httpsReq, HttpResponse.BodyHandlers.ofString()); - System.out.println("jdk https: status=" + r2.statusCode() + " body=" + r2.body()); + client.send(HttpRequest.newBuilder().uri(URI.create(httpsBase + "/api/health")).header("x-phantom-client", "jdk-httpclient").GET().build(), HttpResponse.BodyHandlers.ofString()); } } - // ------------------------------------------------------------------------- - // 2. Apache HttpClient 5 - // ------------------------------------------------------------------------- - static void runApacheHttpClient(String httpBase, String httpsBase) throws Exception { - var sslSocketFactory = SSLConnectionSocketFactoryBuilder.create() - .setSslContext(SSLContextBuilder.create() - .loadTrustMaterial((chain, authType) -> true) - .build()) - .setHostnameVerifier(new NoopHostnameVerifier()) - .build(); - - var connManager = PoolingHttpClientConnectionManagerBuilder.create() - .setSSLSocketFactory(sslSocketFactory) - .build(); - - // SystemDefaultRoutePlanner reads ProxySelector.getDefault(), which - // respects -Dhttp.proxyHost / -Dhttps.proxyHost injected by phantom. - var routePlanner = new SystemDefaultRoutePlanner(ProxySelector.getDefault()); - - try (CloseableHttpClient client = HttpClients.custom() - .setConnectionManager(connManager) - .setRoutePlanner(routePlanner) - .build()) { - - // HTTP + try (CloseableHttpClient client = HttpClients.createSystem()) { HttpGet get1 = new HttpGet(httpBase + "/api/health"); get1.addHeader("x-phantom-client", "apache-httpclient"); - String body1 = client.execute(get1, response -> - EntityUtils.toString(response.getEntity())); - System.out.println("apache http: body=" + body1); - - // HTTPS + client.execute(get1, response -> { + EntityUtils.consume(response.getEntity()); + return null; + }); if (httpsBase != null) { HttpGet get2 = new HttpGet(httpsBase + "/api/health"); get2.addHeader("x-phantom-client", "apache-httpclient"); - String body2 = client.execute(get2, response -> - EntityUtils.toString(response.getEntity())); - System.out.println("apache https: body=" + body2); + client.execute(get2, response -> { + EntityUtils.consume(response.getEntity()); + return null; + }); } } } - // ------------------------------------------------------------------------- - // Main - // ------------------------------------------------------------------------- + static void runNettyHttpClient(String httpBase, String httpsBase) throws Exception { + SslContext sslContext = SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE).build(); + reactor.netty.http.client.HttpClient client = reactor.netty.http.client.HttpClient.create() + .secure(spec -> spec.sslContext(sslContext)) + .proxyWithSystemProperties(); + client.headers(h -> h.add("x-phantom-client", "netty-httpclient")).get().uri(httpBase + "/api/health").response().block(); + if (httpsBase != null) { + client.headers(h -> h.add("x-phantom-client", "netty-httpclient")).get().uri(httpsBase + "/api/health").response().block(); + } + } - public static void main(String[] args) throws Exception { - String httpBase = System.getenv("BACKEND_HTTP_URL"); - String httpsBase = System.getenv("BACKEND_HTTPS_URL"); + static void runJettyHttpClient(String httpBase, String httpsBase) throws Exception { + org.eclipse.jetty.client.HttpClient client = new org.eclipse.jetty.client.HttpClient(); + String proxyHost = System.getProperty("http.proxyHost", "127.0.0.1"); + int proxyPort = Integer.getInteger("http.proxyPort", 8080); + client.getProxyConfiguration().getProxies().add(new HttpProxy(proxyHost, proxyPort)); + client.start(); + try { + client.newRequest(httpBase + "/api/health").header("x-phantom-client", "jetty-httpclient").send(); + } finally { + client.stop(); + } + } + + static void runOkHttpClient(String httpBase, String httpsBase) throws Exception { + // For OkHttp, we need to explicitly trust all certs if we're not using bytecode hooking. + TrustManager[] trustAllCerts = new TrustManager[]{ + new X509TrustManager() { + public void checkClientTrusted(X509Certificate[] chain, String authType) {} + public void checkServerTrusted(X509Certificate[] chain, String authType) {} + public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[]{}; } + } + }; + SSLContext sslContext = SSLContext.getInstance("SSL"); + sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); + + okhttp3.OkHttpClient client = new okhttp3.OkHttpClient.Builder() + .sslSocketFactory(sslContext.getSocketFactory(), (X509TrustManager)trustAllCerts[0]) + .hostnameVerifier((hostname, session) -> true) + .build(); + + // HTTP + okhttp3.Request req1 = new okhttp3.Request.Builder().url(httpBase + "/api/health").header("x-phantom-client", "okhttp-httpclient").build(); + try (okhttp3.Response resp1 = client.newCall(req1).execute()) { + EntityUtils.consume(null); // Just closing + } - if (httpBase == null) { - System.err.println("BACKEND_HTTP_URL is required"); - System.exit(1); + // HTTPS + if (httpsBase != null) { + okhttp3.Request req2 = new okhttp3.Request.Builder().url(httpsBase + "/api/health").header("x-phantom-client", "okhttp-httpclient").build(); + try (okhttp3.Response resp2 = client.newCall(req2).execute()) { + EntityUtils.consume(null); + } } + } + public static void main(String[] args) throws Exception { + String httpBase = System.getenv("BACKEND_HTTP_URL"); + String httpsBase = System.getenv("BACKEND_HTTPS_URL"); runJdkHttpClient(httpBase, httpsBase); runApacheHttpClient(httpBase, httpsBase); - + runNettyHttpClient(httpBase, httpsBase); + runJettyHttpClient(httpBase, httpsBase); + runOkHttpClient(httpBase, httpsBase); System.out.println("CLIENT_DONE"); } } diff --git a/tests/proxy_java_clients_integration.rs b/tests/proxy_java_clients_integration.rs index 8585aad..e3fd159 100644 --- a/tests/proxy_java_clients_integration.rs +++ b/tests/proxy_java_clients_integration.rs @@ -265,20 +265,29 @@ fn test_proxy_captures_java_http_clients() { .filter_map(|l| serde_json::from_str(l).ok()) .collect(); - // 2 clients × 2 schemes (HTTP + HTTPS) = 4 traces + // 5 clients (JDK:2, Apache:2, Netty:2, Jetty:1, OkHttp:2) = 9 traces assert_eq!( traces.len(), - 4, - "Expected 4 traces (2 clients × 2 schemes), got {}.\ + 9, + "Expected 9 traces, got {}.\ \n stdout:\n{stdout_buf}\n stderr:\n{stderr_buf}", traces.len(), ); // ── Per-client assertions ───────────────────────────────────────────── - let clients = ["jdk-httpclient", "apache-httpclient"]; + let clients = [ + "jdk-httpclient", + "apache-httpclient", + "netty-httpclient", + "jetty-httpclient", + "okhttp-httpclient", + ]; for client in clients { for scheme in ["http", "https"] { + if client == "jetty-httpclient" && scheme == "https" { + continue; + } let t = traces .iter() .find(|t| { From 4f23e6d2221f5e4d7524ddbaf15c81811d5e7a63 Mon Sep 17 00:00:00 2001 From: epli2 Date: Tue, 10 Mar 2026 01:11:55 +0900 Subject: [PATCH 10/11] feat(java): embed Java Agent JAR into Rust binary - Use include_bytes! to embed phantom-java-agent.jar at compile time. - Extract JAR to a temporary file at runtime when tracing Java processes. - Ensure the temporary JAR is cleaned up after the process exits using RAII. - This enables a single-binary distribution for both Node.js and Java observability. --- src/main.rs | 54 ++++++++++++++++++++++++----------------------------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/src/main.rs b/src/main.rs index e21cfd6..913e224 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,10 @@ use serde::Serialize; /// Written to a temp file when tracing Node.js processes via `phantom -- node …`. const NODE_PROXY_PRELOAD: &str = include_str!("../tests/apps/node-app/proxy-preload.js"); +/// The Java Agent JAR, embedded at compile time. +/// Written to a temp file when tracing Java processes via `phantom -- java …`. +const JAVA_AGENT_JAR: &[u8] = include_bytes!("../crates/phantom-java-agent/phantom-java-agent.jar"); + // ───────────────────────────────────────────────────────────────────────────── // CLI // ───────────────────────────────────────────────────────────────────────────── @@ -381,22 +385,6 @@ fn is_java_command(exe: &str) -> bool { base == "java" || base == "javaw" } -/// Returns the path to the phantom Java Agent JAR if it exists in the build output. -fn find_java_agent_jar() -> Option { - // Check common build locations relative to the executable. - let exe_dir = std::env::current_exe().ok()?.parent()?.to_path_buf(); - - let candidates = [ - // Development: crates/phantom-java-agent/phantom-java-agent.jar - PathBuf::from("crates/phantom-java-agent/phantom-java-agent.jar"), - // Relative to binary in target/debug/ or target/release/ - exe_dir.join("../../../crates/phantom-java-agent/phantom-java-agent.jar"), - exe_dir.join("phantom-java-agent.jar"), - ]; - - candidates.into_iter().find(|path| path.exists()) -} - /// Spawns `command` as a child process routed through the phantom proxy. /// /// * `HTTP_PROXY` / `http_proxy` are set so plain HTTP is captured. @@ -415,13 +403,16 @@ fn spawn_proxy_child( let exe = &command[0]; let proxy_url = format!("http://127.0.0.1:{proxy_port}"); - let (actual_args, temp_script): (Vec, Option) = if is_node_command(exe) { + let mut temp_script: Option = None; + let mut actual_args = command[1..].to_vec(); + + if is_node_command(exe) { // Write the embedded preload script to a temp file. let script_path = std::env::temp_dir().join(format!("phantom-preload-{}.js", std::process::id())); std::fs::write(&script_path, NODE_PROXY_PRELOAD) .map_err(|e| anyhow::anyhow!("failed to write proxy preload script: {e}"))?; - let ts = TempScript(script_path.clone()); + temp_script = Some(TempScript(script_path.clone())); // Prepend --require