From 4410bc38992fb14660a036bf7e029353b78db03e Mon Sep 17 00:00:00 2001 From: epli2 Date: Thu, 5 Mar 2026 10:20:03 +0900 Subject: [PATCH 1/8] feat(test): add non-invasive Node.js proxy integration test with HTTPS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an integration test that verifies phantom captures HTTP traces from an existing Node.js app without modifying its code — the same philosophy as LD_PRELOAD, but for Node.js. Changes: - tests/apps/node-app/client.js: proxy-unaware Node.js app (BACKEND_*_URL only) - tests/apps/node-app/proxy-preload.js: --require shim that monkey-patches http/https to route through HTTP_PROXY (CONNECT tunnel for HTTPS) - tests/proxy_node_integration.rs: Rust integration test with in-process HTTP (std::net) and HTTPS (rustls) mock backends; verifies 4 traces (2 HTTP + 2 HTTPS MITM) including headers, bodies, trace/span IDs - crates/phantom-capture: add --insecure mode (NoCertVerifier via rustls dangerous API + custom hyper-rustls client) for self-signed backend certs - src/main.rs: expose --insecure CLI flag - Cargo.toml: add rustls/rcgen/rustls-pki-types dev-deps Co-Authored-By: Claude Sonnet 4.5 --- Cargo.lock | 84 ++++++ Cargo.toml | 8 + crates/phantom-capture/Cargo.toml | 3 + crates/phantom-capture/src/proxy.rs | 119 ++++++++- src/main.rs | 6 +- tests/apps/node-app/client.js | 102 ++++++++ tests/apps/node-app/package.json | 6 + tests/apps/node-app/proxy-preload.js | 163 ++++++++++++ tests/proxy_node_integration.rs | 370 +++++++++++++++++++++++++++ 9 files changed, 847 insertions(+), 14 deletions(-) create mode 100644 tests/apps/node-app/client.js create mode 100644 tests/apps/node-app/package.json create mode 100644 tests/apps/node-app/proxy-preload.js create mode 100644 tests/proxy_node_integration.rs diff --git a/Cargo.lock b/Cargo.lock index 629480c..996bcca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -379,6 +379,22 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1000,6 +1016,7 @@ dependencies = [ "hyper-util", "log 0.4.29", "rustls", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", @@ -1502,6 +1519,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "option-ext" version = "0.2.0" @@ -1589,8 +1612,12 @@ dependencies = [ "phantom-core", "phantom-storage", "phantom-tui", + "rcgen", + "rustls", + "rustls-pki-types", "serde", "serde_json", + "tempfile", "tokio", "tracing", "tracing-subscriber", @@ -1618,8 +1645,11 @@ dependencies = [ "http-body-util", "hudsucker", "hyper", + "hyper-rustls", + "hyper-util", "phantom-core", "rand", + "rustls", "serde", "serde_json", "tokio", @@ -1931,6 +1961,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -1963,6 +2015,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -1975,6 +2036,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.2.2" diff --git a/Cargo.toml b/Cargo.toml index b0f9854..4afa1c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,14 @@ clap = { version = "4", features = ["derive"] } dirs = "6" anyhow = "1" +[dev-dependencies] +serde_json = { workspace = true } +serde = { workspace = true } +tempfile = "3" +rustls = "0.22" +rcgen = "0.13" +rustls-pki-types = "1" + [dev-dependencies.cargo-husky] version = "1" default-features = false # Disable default run-cargo-test diff --git a/crates/phantom-capture/Cargo.toml b/crates/phantom-capture/Cargo.toml index a5940d3..9ddab6c 100644 --- a/crates/phantom-capture/Cargo.toml +++ b/crates/phantom-capture/Cargo.toml @@ -15,6 +15,9 @@ bytes = "1" http-body-util = "0.1" serde = { version = "1", features = ["derive"] } serde_json = "1" +rustls = "0.22" +hyper-rustls = "0.26" +hyper-util = { version = "0.1", features = ["client-legacy", "tokio"] } # base64 decoding for LD_PRELOAD agent messages (Linux) [target.'cfg(target_os = "linux")'.dependencies] diff --git a/crates/phantom-capture/src/proxy.rs b/crates/phantom-capture/src/proxy.rs index d30e9ca..04fe55b 100644 --- a/crates/phantom-capture/src/proxy.rs +++ b/crates/phantom-capture/src/proxy.rs @@ -18,14 +18,16 @@ const MAX_BODY_SIZE: usize = 1024 * 1024; pub struct ProxyCaptureBackend { listen_port: u16, + insecure: bool, shutdown_tx: Option>, task_handle: Option>, } impl ProxyCaptureBackend { - pub fn new(listen_port: u16) -> Self { + pub fn new(listen_port: u16, insecure: bool) -> Self { Self { listen_port, + insecure, shutdown_tx: None, task_handle: None, } @@ -43,6 +45,7 @@ impl CaptureBackend for ProxyCaptureBackend { }; let port = self.listen_port; + let insecure = self.insecure; let task_handle = tokio::spawn(async move { let (key_pair, ca_cert) = generate_ca(); @@ -51,18 +54,34 @@ impl CaptureBackend for ProxyCaptureBackend { let addr = SocketAddr::from(([127, 0, 0, 1], port)); info!("Starting proxy on {addr}"); - let proxy = Proxy::builder() - .with_addr(addr) - .with_rustls_client() - .with_ca(ca) - .with_http_handler(handler) - .with_graceful_shutdown(async { - shutdown_rx.await.ok(); - }) - .build(); - - if let Err(e) = proxy.start().await { - warn!("Proxy error: {e}"); + if insecure { + info!("TLS verification disabled (--insecure)"); + let client = build_insecure_client(); + let proxy = Proxy::builder() + .with_addr(addr) + .with_client(client) + .with_ca(ca) + .with_http_handler(handler) + .with_graceful_shutdown(async { + shutdown_rx.await.ok(); + }) + .build(); + if let Err(e) = proxy.start().await { + warn!("Proxy error: {e}"); + } + } else { + let proxy = Proxy::builder() + .with_addr(addr) + .with_rustls_client() + .with_ca(ca) + .with_http_handler(handler) + .with_graceful_shutdown(async { + shutdown_rx.await.ok(); + }) + .build(); + if let Err(e) = proxy.start().await { + warn!("Proxy error: {e}"); + } } }); @@ -273,3 +292,77 @@ fn rand_bytes() -> [u8; N] { buf.iter_mut().for_each(|b| *b = rand::random()); buf } + +// ───────────────────────────────────────────────────────────────────────────── +// Insecure TLS client (--insecure mode) +// ───────────────────────────────────────────────────────────────────────────── + +/// Build a hyper client that skips all TLS certificate verification. +/// Used with `--insecure` for testing against backends with self-signed certs. +fn build_insecure_client() -> hyper_util::client::legacy::Client< + hyper_rustls::HttpsConnector, + Body, +> { + let tls_config = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(std::sync::Arc::new(NoCertVerifier)) + .with_no_client_auth(); + + let https = hyper_rustls::HttpsConnectorBuilder::new() + .with_tls_config(tls_config) + .https_or_http() + .enable_http1() + .build(); + + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()).build(https) +} + +/// A [`rustls::client::danger::ServerCertVerifier`] that accepts any certificate. +/// For testing only — never use in production. +#[derive(Debug)] +struct NoCertVerifier; + +impl rustls::client::danger::ServerCertVerifier for NoCertVerifier { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + rustls::SignatureScheme::ECDSA_NISTP384_SHA384, + rustls::SignatureScheme::ECDSA_NISTP256_SHA256, + rustls::SignatureScheme::ED25519, + rustls::SignatureScheme::RSA_PSS_SHA512, + rustls::SignatureScheme::RSA_PSS_SHA384, + rustls::SignatureScheme::RSA_PSS_SHA256, + rustls::SignatureScheme::RSA_PKCS1_SHA512, + rustls::SignatureScheme::RSA_PKCS1_SHA384, + rustls::SignatureScheme::RSA_PKCS1_SHA256, + ] + } +} diff --git a/src/main.rs b/src/main.rs index 3058ebc..cfb1e22 100644 --- a/src/main.rs +++ b/src/main.rs @@ -52,6 +52,10 @@ struct Cli { #[arg(short, long, default_value = "8080")] port: u16, + /// Skip TLS certificate verification for backend connections (testing only). + #[arg(long, default_value = "false")] + insecure: bool, + /// Directory for trace storage. #[arg(short, long)] data_dir: Option, @@ -243,7 +247,7 @@ async fn main() -> anyhow::Result<()> { // ───────────────────────────────────────────────────────────────────────────── async fn run_proxy(cli: Cli, store: Arc) -> anyhow::Result<()> { - let mut backend = ProxyCaptureBackend::new(cli.port); + let mut backend = ProxyCaptureBackend::new(cli.port, cli.insecure); let backend_name = backend.name().to_string(); let trace_rx = backend.start().map_err(|e| anyhow::anyhow!("{e}"))?; diff --git a/tests/apps/node-app/client.js b/tests/apps/node-app/client.js new file mode 100644 index 0000000..2967839 --- /dev/null +++ b/tests/apps/node-app/client.js @@ -0,0 +1,102 @@ +// A normal Node.js application that makes HTTP and HTTPS requests. +// This file contains ZERO proxy configuration — it talks directly to backends. +// Proxy injection is done externally via: node --require ./proxy-preload.js client.js +// +// Environment: +// BACKEND_HTTP_URL — e.g. http://127.0.0.1:3000 +// BACKEND_HTTPS_URL — e.g. https://127.0.0.1:3443 (optional) + +"use strict"; + +const http = require("http"); +const https = require("https"); + +const BACKEND_HTTP_URL = process.env.BACKEND_HTTP_URL; +const BACKEND_HTTPS_URL = process.env.BACKEND_HTTPS_URL; + +if (!BACKEND_HTTP_URL) { + console.error("BACKEND_HTTP_URL is required"); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Simple promise wrappers around http.get / https.request +// --------------------------------------------------------------------------- + +function httpGet(url) { + return new Promise((resolve, reject) => { + http.get(url, (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => resolve({ status: res.statusCode, body: data })); + }).on("error", reject); + }); +} + +function httpsGet(url) { + return new Promise((resolve, reject) => { + https.get(url, { rejectUnauthorized: false }, (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => resolve({ status: res.statusCode, body: data })); + }).on("error", reject); + }); +} + +function httpsPost(url, body) { + return new Promise((resolve, reject) => { + const bodyStr = JSON.stringify(body); + const urlObj = new URL(url); + const opts = { + hostname: urlObj.hostname, + port: urlObj.port, + path: urlObj.pathname, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(bodyStr), + }, + rejectUnauthorized: false, + }; + const req = https.request(opts, (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => resolve({ status: res.statusCode, body: data })); + }); + req.on("error", reject); + req.write(bodyStr); + req.end(); + }); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + // ── HTTP requests ──────────────────────────────────────────────────── + const r1 = await httpGet(`${BACKEND_HTTP_URL}/api/health`); + console.log(`http health: status=${r1.status} body=${r1.body}`); + + const r2 = await httpGet(`${BACKEND_HTTP_URL}/api/users`); + console.log(`http users: status=${r2.status} body=${r2.body}`); + + // ── HTTPS requests (only if BACKEND_HTTPS_URL is provided) ────────── + if (BACKEND_HTTPS_URL) { + const r3 = await httpsGet(`${BACKEND_HTTPS_URL}/api/health`); + console.log(`https health: status=${r3.status} body=${r3.body}`); + + const r4 = await httpsPost(`${BACKEND_HTTPS_URL}/api/users`, { + name: "Charlie", + email: "charlie@example.com", + }); + console.log(`https create: status=${r4.status} body=${r4.body}`); + } + + console.log("CLIENT_DONE"); +} + +main().catch((err) => { + console.error("Client error:", err); + process.exit(1); +}); diff --git a/tests/apps/node-app/package.json b/tests/apps/node-app/package.json new file mode 100644 index 0000000..ce75a7e --- /dev/null +++ b/tests/apps/node-app/package.json @@ -0,0 +1,6 @@ +{ + "name": "phantom-test-app", + "version": "0.0.0", + "private": true, + "description": "Test Node.js app for phantom proxy integration tests (no external dependencies)" +} diff --git a/tests/apps/node-app/proxy-preload.js b/tests/apps/node-app/proxy-preload.js new file mode 100644 index 0000000..564b173 --- /dev/null +++ b/tests/apps/node-app/proxy-preload.js @@ -0,0 +1,163 @@ +// Transparent HTTP/HTTPS proxy injection via Node.js --require. +// +// When loaded with `node --require ./proxy-preload.js app.js`, this script +// monkey-patches the built-in `http` and `https` modules to route all outbound +// requests through an HTTP proxy — without touching the application code. +// +// Activated by the HTTP_PROXY (or http_proxy) environment variable. +// If neither is set, this script is a no-op and the app runs normally. +// +// This is the Node.js equivalent of LD_PRELOAD for transparent interception. + +"use strict"; + +const PROXY_URL = process.env.HTTP_PROXY || process.env.http_proxy; +if (!PROXY_URL) { + // No proxy configured — do nothing. + return; +} + +const http = require("http"); +const https = require("https"); +const tls = require("tls"); +const { URL } = require("url"); + +const proxy = new URL(PROXY_URL); +const proxyHost = proxy.hostname; +const proxyPort = parseInt(proxy.port, 10); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Convert a URL string or URL object to http.request options. */ +function urlToOptions(input) { + const u = typeof input === "string" ? new URL(input) : input; + return { + protocol: u.protocol, + hostname: u.hostname, + port: u.port || (u.protocol === "https:" ? 443 : 80), + path: u.pathname + u.search, + hash: u.hash, + }; +} + +/** Normalise the (url, options, callback) overloaded arguments. */ +function normaliseArgs(args) { + let options, callback; + if (typeof args[0] === "string" || args[0] instanceof URL) { + const urlOpts = urlToOptions(args[0]); + options = + typeof args[1] === "object" && typeof args[1] !== "function" + ? { ...urlOpts, ...args[1] } + : urlOpts; + callback = typeof args[1] === "function" ? args[1] : args[2]; + } else { + options = args[0] || {}; + callback = args[1]; + } + return { options, callback }; +} + +// --------------------------------------------------------------------------- +// HTTP patching — rewrite request target to go through proxy +// --------------------------------------------------------------------------- + +const origHttpRequest = http.request; +const origHttpGet = http.get; + +http.request = function (...args) { + const { options, callback } = normaliseArgs(args); + + const host = options.hostname || options.host || "localhost"; + const port = options.port || 80; + const path = options.path || "/"; + + // Build the absolute URI the proxy expects. + const absoluteUri = `http://${host}:${port}${path}`; + + const proxyOpts = { + ...options, + hostname: proxyHost, + port: proxyPort, + path: absoluteUri, + host: `${proxyHost}:${proxyPort}`, + headers: { + ...options.headers, + Host: port == 80 ? host : `${host}:${port}`, + }, + }; + + return origHttpRequest.call(http, proxyOpts, callback); +}; + +http.get = function (...args) { + const req = http.request(...args); + req.end(); + return req; +}; + +// --------------------------------------------------------------------------- +// HTTPS patching — CONNECT tunnel through proxy, then TLS handshake +// --------------------------------------------------------------------------- + +const origHttpsRequest = https.request; +const origHttpsGet = https.get; + +/** + * Custom HTTPS agent that tunnels through the HTTP proxy using CONNECT. + * + * Flow: + * 1. Open TCP to proxy via origHttpRequest (bypasses our http.request patch) + * 2. Send CONNECT target:port + * 3. On 200, wrap the raw socket with tls.connect + * 4. Return the TLS socket to Node's https machinery + */ +class ProxyTunnelAgent extends https.Agent { + createConnection(options, oncreate) { + const targetHost = options.hostname || options.host; + const targetPort = options.port || 443; + + const connectReq = origHttpRequest.call(http, { + hostname: proxyHost, + port: proxyPort, + method: "CONNECT", + path: `${targetHost}:${targetPort}`, + headers: { Host: `${targetHost}:${targetPort}` }, + }); + + connectReq.on("connect", (_res, socket) => { + const tlsSocket = tls.connect( + { + socket, + servername: targetHost, + // Trust the MITM proxy's dynamically-generated certificates. + rejectUnauthorized: false, + }, + () => oncreate(null, tlsSocket) + ); + tlsSocket.on("error", (err) => oncreate(err)); + }); + + connectReq.on("error", (err) => oncreate(err)); + connectReq.end(); + } +} + +const tunnelAgent = new ProxyTunnelAgent({ + keepAlive: false, + rejectUnauthorized: false, +}); + +https.request = function (...args) { + const { options, callback } = normaliseArgs(args); + // Force all HTTPS requests through the tunnel agent. + options.agent = tunnelAgent; + return origHttpsRequest.call(https, options, callback); +}; + +https.get = function (...args) { + const req = https.request(...args); + req.end(); + return req; +}; diff --git a/tests/proxy_node_integration.rs b/tests/proxy_node_integration.rs new file mode 100644 index 0000000..1246f13 --- /dev/null +++ b/tests/proxy_node_integration.rs @@ -0,0 +1,370 @@ +//! Integration test: Node.js app → phantom proxy → Rust mock backend +//! +//! Verifies non-invasive proxy tracing: the Node.js client has ZERO proxy +//! awareness. A `--require proxy-preload.js` script transparently patches +//! `http`/`https` to route through phantom (like LD_PRELOAD for Node.js). +//! +//! Tests both HTTP and HTTPS (MITM) capture. +//! +//! Requirements: `node` on PATH. +//! Run: `cargo test --test proxy_node_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 +} + +struct ProcessGuard(Option); +impl ProcessGuard { + fn new(child: std::process::Child) -> Self { + Self(Some(child)) + } + fn take(&mut self) -> std::process::Child { + self.0.take().expect("already consumed") + } +} +impl Drop for ProcessGuard { + fn drop(&mut self) { + if let Some(ref mut c) = self.0 { + let _ = c.kill(); + let _ = c.wait(); + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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); +} + +/// Start a plain HTTP mock backend on `port`. Returns a join handle. +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, + } + } + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_proxy_captures_node_app_traffic() { + // Pre-flight: node available? + if Command::new("node") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_err() + { + eprintln!("SKIP: `node` not found"); + return; + } + + let phantom_bin = env!("CARGO_BIN_EXE_phantom"); + let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/apps/node-app"); + let tmp_dir = tempfile::tempdir().expect("tempdir"); + + 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 HTTP backend ─────────────────────────────────────────────── + let _http_thread = start_http_backend(http_port); + assert!( + wait_for_port(http_port, Duration::from_secs(3)), + "HTTP backend" + ); + + // ── 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" + ); + + // ── Start phantom proxy (--insecure to accept self-signed backend) ── + let phantom_proc = Command::new(phantom_bin) + .args([ + "--backend", + "proxy", + "--output", + "jsonl", + "--port", + &proxy_port.to_string(), + "--insecure", + "--data-dir", + ]) + .arg(tmp_dir.path()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn phantom"); + + let mut phantom_guard = ProcessGuard::new(phantom_proc); + assert!( + wait_for_port(proxy_port, Duration::from_secs(5)), + "phantom proxy" + ); + + // ── Run client via --require proxy-preload.js ──────────────────────── + let preload = app_dir.join("proxy-preload.js"); + let client_output = Command::new("node") + .args(["--require", &preload.to_string_lossy()]) + .arg(app_dir.join("client.js")) + .env("BACKEND_HTTP_URL", format!("http://127.0.0.1:{http_port}")) + .env( + "BACKEND_HTTPS_URL", + format!("https://localhost:{https_port}"), + ) + .env("HTTP_PROXY", format!("http://127.0.0.1:{proxy_port}")) + .env("NODE_TLS_REJECT_UNAUTHORIZED", "0") + .output() + .expect("run client.js"); + + let client_stdout = String::from_utf8_lossy(&client_output.stdout); + let client_stderr = String::from_utf8_lossy(&client_output.stderr); + assert!( + client_output.status.success(), + "client.js failed.\n stdout: {client_stdout}\n stderr: {client_stderr}" + ); + assert!( + client_stdout.contains("CLIENT_DONE"), + "client.js incomplete.\n stdout: {client_stdout}\n stderr: {client_stderr}" + ); + + // ── Collect phantom output ─────────────────────────────────────────── + std::thread::sleep(Duration::from_millis(500)); + + let mut phantom_proc = phantom_guard.take(); + let mut phantom_stdout = phantom_proc.stdout.take().unwrap(); + let mut phantom_stderr_h = phantom_proc.stderr.take().unwrap(); + phantom_proc.kill().ok(); + phantom_proc.wait().ok(); + + let mut stdout_buf = String::new(); + phantom_stdout.read_to_string(&mut stdout_buf).ok(); + let mut stderr_buf = String::new(); + phantom_stderr_h.read_to_string(&mut stderr_buf).ok(); + + // ── Parse JSONL traces ─────────────────────────────────────────────── + let traces: Vec = stdout_buf + .lines() + .filter(|l| l.starts_with('{')) + .filter_map(|l| serde_json::from_str(l).ok()) + .collect(); + + assert_eq!( + traces.len(), + 4, + "Expected 4 traces (2 HTTP + 2 HTTPS), got {}.\n stdout:\n{stdout_buf}\n stderr:\n{stderr_buf}\n client:\n{client_stdout}", + 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: 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")) + ); + + // ── 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: 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")) + ); + assert!( + create_https["response_body"] + .as_str() + .is_some_and(|b| b.contains("Charlie")) + ); + + // ── 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 4 traces (2 HTTP + 2 HTTPS) verified."); +} From 6cec9b60f37d3dab6e4b20ac5904b712e7b226ad Mon Sep 17 00:00:00 2001 From: epli2 Date: Thu, 5 Mar 2026 20:44:28 +0900 Subject: [PATCH 2/8] feat: integrate proxy-preload.js into phantom CLI (phantom -- node app.js) Embed proxy-preload.js in the phantom binary via include_str! so that `phantom -- node app.js` transparently injects HTTPS interception into any Node.js app without code changes. Key additions to src/main.rs: - NODE_PROXY_PRELOAD const: preload script embedded at compile time - TempScript RAII guard: writes preload to /tmp, deletes on drop - is_node_command(): detects node/nodejs executables - spawn_proxy_child(): sets HTTP_PROXY; for node prepends --require - wait_for_proxy(): async poll until proxy port is ready - run_proxy() updated to call spawn_proxy_child when -- CMD is given Integration test updated to use `phantom -- node client.js` instead of manually managing the preload script and proxy env vars. Co-Authored-By: Claude Sonnet 4.6 --- src/main.rs | 145 +++++++++++++++++++++++++++++--- tests/proxy_node_integration.rs | 80 ++++-------------- 2 files changed, 152 insertions(+), 73 deletions(-) diff --git a/src/main.rs b/src/main.rs index cfb1e22..39ccffc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::UNIX_EPOCH; @@ -10,6 +10,14 @@ use phantom_core::trace::HttpTrace; use phantom_storage::FjallTraceStore; use serde::Serialize; +// ───────────────────────────────────────────────────────────────────────────── +// Embedded proxy preload script (Node.js transparent injection) +// ───────────────────────────────────────────────────────────────────────────── + +/// The proxy-preload.js content, embedded at compile time. +/// 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"); + // ───────────────────────────────────────────────────────────────────────────── // CLI // ───────────────────────────────────────────────────────────────────────────── @@ -66,10 +74,21 @@ struct Cli { #[arg(long, value_name = "PATH")] agent_lib: Option, - /// Command to run with LD_PRELOAD injected (required for --backend ldpreload). + /// Command to spawn and trace. /// /// Everything after `--` is treated as the command. - /// Example: `phantom --backend ldpreload --agent-lib ./libphantom_agent.so -- curl http://example.com` + /// + /// In proxy mode (`--backend proxy`): the command is launched with + /// `HTTP_PROXY` set automatically. For Node.js commands (`node`/`nodejs`) + /// the proxy-preload script is also injected via `--require` so that HTTPS + /// is intercepted transparently without any changes to the application. + /// + /// In ldpreload mode (`--backend ldpreload`): the LD_PRELOAD agent is + /// injected into the child process (Linux only). + /// + /// Examples: + /// `phantom -- node app.js` + /// `phantom --backend ldpreload --agent-lib ./libphantom_agent.so -- curl http://example.com` #[arg(last = true, value_name = "CMD")] command: Vec, } @@ -246,19 +265,100 @@ async fn main() -> anyhow::Result<()> { // Proxy backend // ───────────────────────────────────────────────────────────────────────────── +/// RAII guard that deletes a temporary script file on drop. +struct TempScript(PathBuf); + +impl Drop for TempScript { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +/// Returns `true` if `exe` (path or bare name) resolves to `node` or `nodejs`. +fn is_node_command(exe: &str) -> bool { + let base = Path::new(exe) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(exe); + base == "node" || base == "nodejs" +} + +/// 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. +/// +/// Returns `(child, Option)`. The `TempScript` must be kept alive +/// until after the child exits so the file is not deleted prematurely. +fn spawn_proxy_child( + command: &[String], + proxy_port: u16, +) -> anyhow::Result<(std::process::Child, Option)> { + 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) { + // 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()); + + // Prepend --require