From 9302c89980d647154fc4b907dbc9b3aeae04a0db Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:11:54 +0000 Subject: [PATCH] feat(docker): support tracing an app in another container via a phantom sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --bind (default 127.0.0.1, unchanged behavior) so the MITM proxy can also listen on 0.0.0.0, making it reachable from other Docker containers on the same network. phantom itself never spawns the target process in this mode — a separately-managed app container is configured with HTTP_PROXY/HTTPS_PROXY pointing at the phantom sidecar service, the same "manual" pattern phantom already supported on a single host, now crossing a Docker network boundary. Applies to `phantom run` only; `phantom mcp` sessions keep binding loopback only. The MITM CA certificate is now written to /ca.pem on every `phantom run` (previously only ever written to a PID-scoped temp file, and only when phantom spawned a PHP child for curl.cainfo injection) so it can be bind-mounted into another container's trust store for HTTPS capture. Add examples/docker-sidecar/ with a runnable compose.yaml and a README covering the per-client/language CA-trust mechanics (there's no single env var every HTTP client honors) — explicitly flagged as unverified end-to-end in this environment (no Docker daemon available here); `docker compose config` confirmed the compose file's structure only. Rebuilt against the subcommand-based CLI restructuring (`phantom run`/ `list`/`get`/.../`mcp`) so this now targets src/cli.rs's RunArgs and src/commands/run.rs instead of the old flat main.rs. Scope: HTTP_PROXY-sidecar only, agreed with the user up front — transparent iptables/eBPF interception and cross-container ldpreload tracing are both out of scope, left as possible future work. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GbCUVmJxDEWtgJpE9drzga --- AGENTS.md | 31 ++++- crates/phantom-capture/src/proxy.rs | 9 +- examples/docker-sidecar/README.md | 90 ++++++++++++++ examples/docker-sidecar/compose.yaml | 62 +++++++++ src/cli.rs | 26 +++- src/commands/run.rs | 78 +++++++++--- src/mcp/session.rs | 12 +- src/runner.rs | 29 ++++- tests/proxy_bind_integration.rs | 180 +++++++++++++++++++++++++++ 9 files changed, 488 insertions(+), 29 deletions(-) create mode 100644 examples/docker-sidecar/README.md create mode 100644 examples/docker-sidecar/compose.yaml create mode 100644 tests/proxy_bind_integration.rs diff --git a/AGENTS.md b/AGENTS.md index 91048aa..3917574 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,11 +24,14 @@ tests/ proxy_node_integration.rs # Integration tests: Node.js proxy capture (HTTP + HTTPS) proxy_php_integration.rs # Integration test: PHP curl via proxy backend (HTTP + HTTPS) proxy_php_ldpreload_integration.rs # Integration test: PHP curl via ldpreload backend (Linux only) + proxy_bind_integration.rs # Integration test: --bind 0.0.0.0 + ca.pem export (Docker sidecar mode) cli_query_integration.rs # Integration tests: exit-code propagation, JSONL purity, list/get/clear, store lock UX mcp_stdio_integration.rs # Integration test: MCP server via raw JSON-RPC over stdio apps/node-app/ # Test Node.js app (client.js, client-alts.js, proxy-preload.js) apps/php-app/ # Test PHP app (client.php, curl extension, PHP 5.3-compatible) integration/ # Shell-based integration tests +examples/ + docker-sidecar/ # phantom as a Docker Compose sidecar tracing a peer container Cargo.toml # Workspace root + binary crate plan.md # Japanese-language technical design document ``` @@ -97,6 +100,7 @@ make check # fmt + clippy + build + test (full CI locally) | `-b, --backend ` | `proxy` | `proxy` (MITM, cross-platform) or `ldpreload` (Linux only, HTTP + HTTPS) | | `-o, --output ` | `tui` | `tui` (interactive) or `jsonl` (stdout stream; exits with the child's exit code) | | `-p, --port ` | `8080` | Proxy capture port | +| `--bind ` | `127.0.0.1` | IP address the proxy binds to. `0.0.0.0` exposes it to other hosts/containers (Docker sidecar mode) — no auth, trusted networks only | | `--insecure` | off | Disable TLS verification for backend servers (self-signed certs) | | `--agent-lib ` | — | Path to `libphantom_agent.so` (ldpreload backend) | | `--fault ` | — | Fault injection rules (repeatable) | @@ -161,6 +165,19 @@ Unlike Node.js, PHP requires **no injected script**: libcurl (the library backin --- +## Docker Sidecar Mode (`--bind`) + +phantom can trace an arbitrary web app already running in its own Docker container, without spawning or managing it — run phantom as a **sidecar container** on the same Docker network, and configure the target container's `HTTP_PROXY`/`HTTPS_PROXY` to point at it. This is the same "manual" proxy-configuration mode phantom always supported on a single host (`HTTP_PROXY=http://127.0.0.1:8080 your-app`), extended across a Docker network boundary via `--bind`. + +- `--bind 0.0.0.0` makes the proxy reachable from other containers (default is `127.0.0.1`, unreachable from outside the container). **No authentication** — only bind `0.0.0.0` on a trusted/private network. +- The MITM CA certificate is written to **`/ca.pem` on every `phantom run`** (not just when phantom spawns a PHP child) — as soon as the proxy is confirmed listening, regardless of whether a `-- ` is given. Bind-mount `` into the target container to make the CA available there for HTTPS trust. It is regenerated fresh every run, so sharing one `--data-dir` across concurrent phantom processes will race on `ca.pem` — use one data dir per phantom instance. +- CA trust is client/language-specific (there is no universal env var): curl uses `CURLOPT_CAINFO`/`--cacert`, Node.js uses `NODE_EXTRA_CA_CERTS`, PHP's curl extension uses `-d curl.cainfo=` (same mechanism as automatic PHP child injection above), the JVM needs a truststore import, Python `requests` uses `REQUESTS_CA_BUNDLE`, and Debian/Ubuntu-based images can use `update-ca-certificates`. +- See `examples/docker-sidecar/` for a runnable `compose.yaml` + walkthrough (not verified end-to-end in the environment it was authored in — no Docker daemon was available there; confirm it in your own environment). +- Out of scope: transparent traffic interception (iptables/eBPF, requiring no target container changes) and tracing across container boundaries via the `ldpreload` backend. Possible future work. +- `phantom mcp` sessions always bind loopback only (no `--bind` there) — Docker sidecar mode applies to `phantom run`. + +--- + ## JSONL Output Schema When `--output jsonl` is used, one JSON object is written per line to stdout. All fields are always present unless marked optional. @@ -249,6 +266,9 @@ cargo test --test proxy_php_integration -- --nocapture cargo build -p phantom-agent cargo test --test proxy_php_ldpreload_integration -- --nocapture +# --bind 0.0.0.0 + ca.pem export test (Docker sidecar mode; requires curl) +cargo test --test proxy_bind_integration -- --nocapture + # CLI query / exit-code / lock UX integration tests (requires curl for the capture parts) cargo test --test cli_query_integration -- --nocapture @@ -279,6 +299,10 @@ Both tests: Both auto-skip if `php` (or its curl extension) is not available. `client.php` (shared by both tests, `tests/apps/php-app/client.php`) is written in PHP 5.3-compatible syntax and takes an optional `PHANTOM_TEST_INSECURE` env var to disable curl peer verification for the ldpreload test, where there is no phantom CA to trust against the mock backend's self-signed cert. +### Docker Sidecar Test (`tests/proxy_bind_integration.rs`) + +Verifies `--bind 0.0.0.0` still proxies correctly (reachable via loopback, since an unspecified bind always includes it) and that `/ca.pem` is written once the proxy is ready. Drives `phantom run ... -- curl ...` rather than a truly standalone/no-child invocation, since JSONL mode only auto-exits on child completion or ctrl-c — the CA-export code path runs unconditionally before the child-spawn branch either way, so this still fully exercises what a Docker sidecar run would do. The fully standalone case is verified manually via `examples/docker-sidecar/`. + ### CLI and MCP Integration Tests | Test | Description | @@ -426,7 +450,7 @@ pub enum StorageError { |---|---| | `src/main.rs` | Subcommand dispatch, exit-code mapping, store opening (with lock hint for query commands) | | `src/cli.rs` | `clap` derive: `Cli`, `Commands`, per-subcommand arg structs, `GlobalOpts` | -| `src/runner.rs` | Child-process spawning: proxy env vars, Node/PHP/Java injection assets, `TempScript`, `wait_for_proxy` | +| `src/runner.rs` | Child-process spawning: proxy env vars, Node/PHP/Java injection assets, `TempScript`, `wait_for_proxy`, `loopback_safe` (bind-address resolution) | | `src/commands/run.rs` | `phantom run`: proxy/ldpreload capture wiring, JSONL output loop with exit-status propagation | | `src/commands/query.rs` | `phantom list/get/search/stats/clear`, `--since/--until` parsing, table/json/jsonl output | | `src/mcp/server.rs` | `PhantomMcp`: rmcp `#[tool_router]` with the 7 MCP tools, `run_mcp` stdio entry | @@ -441,6 +465,9 @@ pub enum StorageError { | `tests/proxy_php_integration.rs` | Integration test: PHP curl HTTP/HTTPS via `proxy` backend (`-d curl.cainfo=` injection) | | `tests/proxy_php_ldpreload_integration.rs` | Integration test: PHP curl HTTP/HTTPS via `ldpreload` backend (Linux only) | | `tests/apps/php-app/client.php` | Test client: curl extension, PHP 5.3-compatible syntax; shared by both PHP tests | +| `tests/proxy_bind_integration.rs` | Integration test: `--bind 0.0.0.0` and `/ca.pem` export (Docker sidecar mode) | +| `examples/docker-sidecar/compose.yaml` | Example: phantom as a Docker Compose sidecar tracing a peer `app` container via HTTP_PROXY | +| `examples/docker-sidecar/README.md` | Walkthrough: sidecar pattern, `--bind` security note, per-client CA trust table | | `crates/phantom-core/src/trace.rs` | `HttpTrace`, `TraceId`, `SpanId`, `HttpMethod` (incl. `FromStr`/`from_hex`) | | `crates/phantom-core/src/query.rs` | `TraceQuery` filter struct + `matches()` predicate, `StatusRange` parsing | | `crates/phantom-core/src/view.rs` | `TraceView` agent-facing JSON DTO + `RenderOptions` (truncation, headers-only, redaction) | @@ -448,7 +475,7 @@ pub enum StorageError { | `crates/phantom-core/src/capture.rs` | `CaptureBackend` trait | | `crates/phantom-core/src/error.rs` | `CaptureError`, `StorageError` | | `crates/phantom-storage/src/fjall_store.rs` | Storage implementation + all storage tests | -| `crates/phantom-capture/src/proxy.rs` | MITM proxy implementation (cross-platform); also exposes `ca_cert_pem()` for PHP `curl.cainfo` injection | +| `crates/phantom-capture/src/proxy.rs` | MITM proxy implementation (cross-platform); `bind_ip` field controls listen address; also exposes `ca_cert_pem()` for PHP `curl.cainfo` injection and Docker sidecar CA export | | `crates/phantom-capture/src/ldpreload.rs` | LD_PRELOAD capture backend (Linux only) | | `crates/phantom-agent/src/lib.rs` | LD_PRELOAD dylib: hooks libc `send`/`recv`/`close` and OpenSSL `SSL_write`/`SSL_read` (HTTPS) | | `crates/phantom-tui/src/app.rs` | TUI state (`App`) and all state mutation | diff --git a/crates/phantom-capture/src/proxy.rs b/crates/phantom-capture/src/proxy.rs index bde3e53..fe0c93f 100644 --- a/crates/phantom-capture/src/proxy.rs +++ b/crates/phantom-capture/src/proxy.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::net::SocketAddr; +use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime}; @@ -20,6 +20,7 @@ use crate::fault::{FaultConfig, FaultRule}; const MAX_BODY_SIZE: usize = 1024 * 1024; pub struct ProxyCaptureBackend { + bind_ip: IpAddr, listen_port: u16, insecure: bool, fault_config: FaultConfig, @@ -29,8 +30,9 @@ pub struct ProxyCaptureBackend { } impl ProxyCaptureBackend { - pub fn new(listen_port: u16, insecure: bool) -> Self { + pub fn new(bind_ip: IpAddr, listen_port: u16, insecure: bool) -> Self { Self { + bind_ip, listen_port, insecure, fault_config: FaultConfig::default(), @@ -67,6 +69,7 @@ impl CaptureBackend for ProxyCaptureBackend { fault_config: Arc::new(self.fault_config.clone()), }; + let bind_ip = self.bind_ip; let port = self.listen_port; let insecure = self.insecure; let ca_cert_pem = Arc::clone(&self.ca_cert_pem); @@ -76,7 +79,7 @@ impl CaptureBackend for ProxyCaptureBackend { *ca_cert_pem.lock().unwrap() = Some(ca_cert.pem()); let ca = RcgenAuthority::new(key_pair, ca_cert, 1000); - let addr = SocketAddr::from(([127, 0, 0, 1], port)); + let addr = SocketAddr::new(bind_ip, port); info!("Starting proxy on {addr}"); if insecure { diff --git a/examples/docker-sidecar/README.md b/examples/docker-sidecar/README.md new file mode 100644 index 0000000..f27b288 --- /dev/null +++ b/examples/docker-sidecar/README.md @@ -0,0 +1,90 @@ +# Docker Sidecar Example + +> **Not verified end-to-end in the environment this was authored in** (no +> working Docker daemon was available there). Please run through the steps +> below yourself and confirm before relying on this pattern. `docker compose +> config` (no daemon required) was used to confirm `compose.yaml`'s syntax +> and structure resolve correctly. + +## The pattern + +phantom normally spawns and traces a process itself (`phantom run -- node app.js`). +This example instead runs phantom as a **sidecar container** on the same +Docker network as a target app container that phantom does not spawn or +manage — the target container is entirely yours (its own image, its own +`Dockerfile`/compose entry). You only need to: + +1. Point the target container's `HTTP_PROXY`/`HTTPS_PROXY` at phantom's + Compose service name (Docker's embedded DNS resolves it), e.g. + `http://phantom:8080`. +2. Trust phantom's MITM CA certificate in the target container for HTTPS + capture (see below — this step is client/language-specific). + +This is the same "manual" proxy-configuration mode phantom already supports +on a single host (`HTTP_PROXY=http://127.0.0.1:8080 your-app`), just crossing +a Docker network boundary instead of staying on loopback. + +**Scope**: this covers the HTTP_PROXY-sidecar approach only. Transparent +traffic interception (iptables/eBPF, no target container changes needed) and +tracing across container boundaries via the `ldpreload` backend are both out +of scope here — possible future work, not implemented. + +## Running it + +```sh +cd examples/docker-sidecar +docker compose up --build +docker compose logs -f phantom | jq . # view captured traces (JSONL) +``` + +`compose.yaml` reuses the repository's existing root `Dockerfile` (a debug +build bundled with test tooling, built for phantom's own CI integration +tests) rather than a dedicated slim release image — deliberate, to keep this +example minimal. A smaller/optimized image is possible future work if +needed. + +The `app` service here is a disposable `curlimages/curl` container making +periodic requests to `httpbin.org` (needs outbound internet) purely so the +example is runnable standalone. Swap it out for your own service. + +## `--bind 0.0.0.0` and security + +By default phantom's proxy only binds `127.0.0.1` (unreachable from other +containers). `--bind 0.0.0.0` is required for a sidecar to be reachable from +other containers on the same Docker network. **The proxy has no +authentication** — only bind `0.0.0.0` on a trusted/private network (an +internal Docker network, never anything internet-facing). + +## Trusting the CA certificate + +phantom writes its MITM CA certificate to `/ca.pem` (here: `/data/ca.pem` +inside the `phantom` container, shared via the `phantom-data` named volume and +mounted read-only into `app` at `/ca/ca.pem`) every time it starts. **There is +no single environment variable that every HTTP client honors** — this is the +same nuance already documented for phantom's Node.js/PHP auto-injection, just +applied manually here since phantom isn't spawning the target process. Some +common cases: + +| Client / language | How to trust `ca.pem` | +|---|---| +| curl / libcurl | `CURLOPT_CAINFO` or `curl --cacert /ca/ca.pem` (used in `compose.yaml`'s example) | +| Node.js | `NODE_EXTRA_CA_CERTS=/ca/ca.pem` | +| PHP curl extension | `-d curl.cainfo=/ca/ca.pem` (same mechanism phantom auto-injects when it spawns a PHP child directly) | +| Java / JVM | Import into a truststore (`keytool -importcert -file /ca/ca.pem ...`) or `-Djavax.net.ssl.trustStore=...` | +| Python `requests` | `REQUESTS_CA_BUNDLE=/ca/ca.pem` | +| Debian/Ubuntu-based images (system-wide) | Copy to `/usr/local/share/ca-certificates/`, then run `update-ca-certificates` | + +If your target app doesn't support any of these, it will fail HTTPS requests +with a certificate error once traffic is routed through phantom — that's +expected until CA trust is configured; plain HTTP capture works regardless. + +## Viewing traces + +- **JSONL (used here, recommended for a non-interactive sidecar)**: + `docker compose logs -f phantom | jq .` streams one JSON trace object per + line as requests happen. +- **TUI**: requires an interactive terminal, so `docker compose up -d` won't + work for it. Use `docker compose run --rm phantom run --bind 0.0.0.0 --port 8080` + instead (drop `--output jsonl` from the command, add `tty: true` / + `stdin_open: true` to the service definition — mirrors the `test-ldpreload` + service in the repository root's `compose.yaml`). diff --git a/examples/docker-sidecar/compose.yaml b/examples/docker-sidecar/compose.yaml new file mode 100644 index 0000000..bc70fd2 --- /dev/null +++ b/examples/docker-sidecar/compose.yaml @@ -0,0 +1,62 @@ +# Minimal example: phantom running as a Docker Compose sidecar, tracing a +# separately-managed "app" container via HTTP_PROXY/HTTPS_PROXY. +# +# phantom does NOT spawn or manage the app container here — it is a peer +# service on the same Docker network. The app owns its own image/command; +# only its HTTP_PROXY/HTTPS_PROXY env vars point at phantom, and it trusts +# phantom's MITM CA certificate (written to /data/ca.pem, shared via the +# `phantom-data` volume) for HTTPS. +# +# Usage: +# cd examples/docker-sidecar +# docker compose up --build +# docker compose logs -f phantom # view captured traces (JSONL) +# +# See README.md for the full walkthrough, including how to adapt CA trust +# for other languages/clients, and an explicit disclaimer that this compose +# file was authored without a Docker daemon available to verify it against — +# please confirm it runs end-to-end in your own environment. + +services: + phantom: + build: + context: ../.. + dockerfile: Dockerfile + command: + - run + - --bind + - "0.0.0.0" + - --port + - "8080" + - --output + - jsonl + - --data-dir + - /data + volumes: + - phantom-data:/data + ports: + - "8080:8080" # optional: lets you also `curl -x http://127.0.0.1:8080 ...` from the host + + app: + image: curlimages/curl:8.10.1 + depends_on: + - phantom + environment: + HTTP_PROXY: http://phantom:8080 + HTTPS_PROXY: http://phantom:8080 + NO_PROXY: "" + volumes: + - phantom-data:/ca:ro # phantom writes /data/ca.pem here, shared read-only + entrypoint: ["/bin/sh", "-c"] + command: + - | + while true; do + curl -s -o /dev/null -w 'HTTP %{http_code} %{url_effective}\n' \ + http://httpbin.org/get || true + curl -s -o /dev/null --cacert /ca/ca.pem -w 'HTTPS %{http_code} %{url_effective}\n' \ + https://httpbin.org/get || true + sleep 5 + done + +volumes: + phantom-data: diff --git a/src/cli.rs b/src/cli.rs index 6455e88..0439c57 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,3 +1,4 @@ +use std::net::IpAddr; use std::path::PathBuf; use clap::{Args, Parser, Subcommand, ValueEnum}; @@ -125,6 +126,11 @@ pub enum Commands { \n\ • Manual (start phantom alone, then configure your app)\n\ Set HTTP_PROXY=http://127.0.0.1:8080 in the target process yourself.\n\ + Same pattern works across a Docker network: run phantom as a sidecar\n\ + container with --bind 0.0.0.0, point the target container's\n\ + HTTP_PROXY/HTTPS_PROXY at it (e.g. http://phantom:8080), and trust\n\ + the MITM CA written to /ca.pem for HTTPS. See\n\ + examples/docker-sidecar/.\n\ \n\ ldpreload (Linux only)\n\ Injects libphantom_agent.so via LD_PRELOAD. Hooks send/recv/close at\n\ @@ -178,7 +184,14 @@ pub enum Commands { cargo build -p phantom-agent\n\ phantom run --backend ldpreload \\\n\ --agent-lib ./target/debug/libphantom_agent.so \\\n\ - -- curl http://api.example.com/v1/users" + -- curl http://api.example.com/v1/users\n\ +\n\ + # Run as a Docker Compose sidecar, tracing a container you don't spawn:\n\ + phantom run --bind 0.0.0.0 --output jsonl --data-dir /data\n\ + # then, in the target container:\n\ + HTTP_PROXY=http://phantom:8080 HTTPS_PROXY=http://phantom:8080 your-app\n\ + # trust /data/ca.pem in the target container for HTTPS. See\n\ + # examples/docker-sidecar/." )] pub struct RunArgs { /// Capture backend: 'proxy' (MITM, cross-platform) or 'ldpreload' (Linux, HTTP + HTTPS). @@ -194,6 +207,17 @@ pub struct RunArgs { #[arg(short, long, default_value = "8080")] pub port: u16, + /// IP address the proxy binds to. + /// + /// Defaults to 127.0.0.1 (localhost only, matches today's behavior). + /// Use 0.0.0.0 to expose the proxy to other hosts/containers — e.g. running + /// phantom as a Docker Compose sidecar service (`phantom`) next to a target + /// container configured with HTTP_PROXY=http://phantom:8080. There is no + /// authentication on the proxy port: only bind 0.0.0.0 on a trusted/private + /// network (an internal Docker network, not anything internet-facing). + #[arg(long, default_value = "127.0.0.1", value_name = "ADDR")] + pub bind: IpAddr, + /// Disable TLS certificate verification for connections to backend servers. /// Use when tracing apps that talk to servers with self-signed certificates. #[arg(long, default_value = "false")] diff --git a/src/commands/run.rs b/src/commands/run.rs index 57c6f74..5bb81d6 100644 --- a/src/commands/run.rs +++ b/src/commands/run.rs @@ -7,9 +7,12 @@ use phantom_core::storage::TraceStore; use phantom_core::trace::HttpTrace; use phantom_core::view::{RenderOptions, TraceView}; use phantom_storage::FjallTraceStore; +use tracing::warn; use crate::cli::{GlobalOpts, OutputMode, RunArgs}; -use crate::runner::{TempScript, build_fault_config, spawn_proxy_child, wait_for_proxy}; +use crate::runner::{ + TempScript, build_fault_config, loopback_safe, spawn_proxy_child, wait_for_proxy, +}; /// Render options for the JSONL stream, from `run` flags. fn jsonl_render_options(args: &RunArgs) -> RenderOptions { @@ -114,17 +117,43 @@ pub async fn run_proxy( store: Arc, ) -> anyhow::Result> { let fault_config = build_fault_config(&args.fault)?; - let mut backend = ProxyCaptureBackend::new(args.port, args.insecure).with_faults(fault_config); + let mut backend = + ProxyCaptureBackend::new(args.bind, args.port, args.insecure).with_faults(fault_config); let backend_name = backend.name().to_string(); let trace_rx = backend.start().map_err(|e| anyhow::anyhow!("{e}"))?; + // Always wait for the proxy to actually be listening — needed both to + // safely inject env vars/CA into a spawned child (if any) and to export + // the MITM CA certificate for out-of-process consumers (e.g. a Docker + // sidecar target container). This also surfaces bind failures (e.g. port + // already in use) as a clear error instead of a silent hang. + let connect_ip = loopback_safe(args.bind); + wait_for_proxy(connect_ip, args.port).await?; + + // Export the MITM CA certificate to a stable path so it can be trusted + // by out-of-process consumers (bind-mount /ca.pem into another + // container's trust store). Regenerated fresh every run. + let ca_cert_pem = backend.ca_cert_pem(); + let ca_cert_path = globals.data_dir.join("ca.pem"); + match ca_cert_pem.as_deref() { + Some(pem) => { + std::fs::write(&ca_cert_path, pem).map_err(|e| { + anyhow::anyhow!("failed to write CA cert to {}: {e}", ca_cert_path.display()) + })?; + } + None => { + // Should not happen: ca_cert_pem is populated before the proxy + // binds, and wait_for_proxy() above already confirmed the port + // is accepting connections. + warn!("MITM CA certificate unavailable after proxy became ready; ca.pem not written"); + } + } + // Optionally spawn a child command routed through the proxy. let child_and_script: Option<(std::process::Child, Option)> = if !args.command.is_empty() { - // Wait for the proxy to be ready before spawning the child. - wait_for_proxy(args.port).await?; - let ca_cert_pem = backend.ca_cert_pem(); - let (child, ts) = spawn_proxy_child(&args.command, args.port, ca_cert_pem.as_deref())?; + let (child, ts) = + spawn_proxy_child(&args.command, connect_ip, args.port, ca_cert_pem.as_deref())?; if !globals.quiet { eprintln!( "phantom: spawned PID {} → {}", @@ -142,12 +171,32 @@ pub async fn run_proxy( OutputMode::Tui => { if !globals.quiet { if args.command.is_empty() { - eprintln!("phantom: proxy listening on 127.0.0.1:{}", args.port); - eprintln!(" Set your HTTP proxy to http://127.0.0.1:{}", args.port); - eprintln!( - " Example: curl -x http://127.0.0.1:{} http://httpbin.org/get", - args.port - ); + if args.bind.is_unspecified() { + eprintln!( + "phantom: proxy listening on {}:{} (all interfaces)", + args.bind, args.port + ); + eprintln!( + " From this host: http://127.0.0.1:{}", + args.port + ); + eprintln!( + " From other Docker containers: http://:{}", + args.port + ); + } else { + eprintln!("phantom: proxy listening on {}:{}", args.bind, args.port); + eprintln!( + " Set your HTTP proxy to http://{}:{}", + args.bind, args.port + ); + eprintln!( + " Example: curl -x http://{}:{} http://httpbin.org/get", + args.bind, args.port + ); + } + eprintln!(" MITM CA certificate: {}", ca_cert_path.display()); + eprintln!(" Trust this CA in your target app/container for HTTPS capture."); } eprintln!("phantom: traces stored in {}", globals.data_dir.display()); } @@ -156,9 +205,10 @@ pub async fn run_proxy( OutputMode::Jsonl => { if !globals.quiet { eprintln!( - "phantom: proxy listening on 127.0.0.1:{} [jsonl mode]", - args.port + "phantom: proxy listening on {}:{} [jsonl mode]", + args.bind, args.port ); + eprintln!(" MITM CA certificate: {}", ca_cert_path.display()); } // Split into child and script guard separately so the TempScript // is NOT dropped until after run_jsonl_output completes (the file diff --git a/src/mcp/session.rs b/src/mcp/session.rs index 8d6fa64..b90a634 100644 --- a/src/mcp/session.rs +++ b/src/mcp/session.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -94,10 +95,15 @@ impl CaptureManager { Some(p) => p, None => free_port()?, }; + // MCP sessions are always local (stdio-driven by an AI coding agent on + // the same host), so the proxy only ever binds loopback here — there + // is no --bind flag in this mode. + let bind_ip = IpAddr::V4(Ipv4Addr::LOCALHOST); let fault_config = build_fault_config(fault)?; - let mut backend = ProxyCaptureBackend::new(port, insecure).with_faults(fault_config); + let mut backend = + ProxyCaptureBackend::new(bind_ip, port, insecure).with_faults(fault_config); let mut trace_rx = backend.start().map_err(|e| anyhow::anyhow!("{e}"))?; - wait_for_proxy(port).await?; + wait_for_proxy(bind_ip, port).await?; // Pump captured traces into the store off the async executor. let trace_count = Arc::new(AtomicU64::new(0)); @@ -124,7 +130,7 @@ impl CaptureManager { let mut temp_script = None; if !command.is_empty() { let ca_cert_pem = backend.ca_cert_pem(); - let (child, ts) = spawn_proxy_child(&command, port, ca_cert_pem.as_deref())?; + let (child, ts) = spawn_proxy_child(&command, bind_ip, port, ca_cert_pem.as_deref())?; child_pid = Some(child.id()); temp_script = ts; *child_state.lock().unwrap() = ChildState::Running; diff --git a/src/runner.rs b/src/runner.rs index 224002a..42f2d6f 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1,3 +1,4 @@ +use std::net::IpAddr; use std::path::{Path, PathBuf}; use phantom_capture::FaultConfig; @@ -75,11 +76,12 @@ fn is_php_command(exe: &str) -> bool { /// until after the child exits so the file is not deleted prematurely. pub fn spawn_proxy_child( command: &[String], + proxy_host: IpAddr, proxy_port: u16, ca_cert_pem: Option<&str>, ) -> anyhow::Result<(std::process::Child, Option)> { let exe = &command[0]; - let proxy_url = format!("http://127.0.0.1:{proxy_port}"); + let proxy_url = format!("http://{proxy_host}:{proxy_port}"); let mut temp_script: Option = None; let mut actual_args = command[1..].to_vec(); @@ -153,8 +155,8 @@ pub fn spawn_proxy_child( temp_script = Some(TempScript(agent_path.clone())); let jvm_opts = format!( - " -Dhttp.proxyHost=127.0.0.1 -Dhttp.proxyPort={proxy_port} \ - -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort={proxy_port} \ + " -Dhttp.proxyHost={proxy_host} -Dhttp.proxyPort={proxy_port} \ + -Dhttps.proxyHost={proxy_host} -Dhttps.proxyPort={proxy_port} \ -Dhttp.nonProxyHosts= -Dhttps.nonProxyHosts= \ -javaagent:{}", agent_path.display() @@ -171,18 +173,33 @@ pub fn spawn_proxy_child( Ok((child, temp_script)) } +/// Resolves the address phantom's own process should use to reach the proxy +/// it just started. An unspecified bind address (0.0.0.0 / ::) always +/// includes the loopback interface, so probing/using it directly via +/// loopback is both correct and avoids needing a routable interface IP. +pub fn loopback_safe(bind: IpAddr) -> IpAddr { + if bind.is_unspecified() { + match bind { + IpAddr::V4(_) => IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + IpAddr::V6(_) => IpAddr::V6(std::net::Ipv6Addr::LOCALHOST), + } + } else { + bind + } +} + /// Poll until the proxy port is accepting connections (or timeout after 5 s). -pub async fn wait_for_proxy(port: u16) -> anyhow::Result<()> { +pub async fn wait_for_proxy(connect_ip: IpAddr, port: u16) -> anyhow::Result<()> { let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); loop { - if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")) + if tokio::net::TcpStream::connect((connect_ip, port)) .await .is_ok() { return Ok(()); } if tokio::time::Instant::now() >= deadline { - anyhow::bail!("proxy did not become ready on port {port} within 5 s"); + anyhow::bail!("proxy did not become ready on {connect_ip}:{port} within 5 s"); } tokio::time::sleep(std::time::Duration::from_millis(50)).await; } diff --git a/tests/proxy_bind_integration.rs b/tests/proxy_bind_integration.rs new file mode 100644 index 0000000..dcb3d92 --- /dev/null +++ b/tests/proxy_bind_integration.rs @@ -0,0 +1,180 @@ +//! Integration test: `--bind 0.0.0.0` and standalone-mode CA cert export. +//! +//! Verifies: +//! - the proxy actually listens on 0.0.0.0 (reachable via loopback, since +//! an unspecified bind always includes it) and still proxies correctly +//! - /ca.pem is written once the proxy is ready +//! +//! This drives phantom via `run ... -- curl ...` (not truly standalone/no-child) +//! because JSONL mode only auto-exits on child completion or ctrl-c, and a +//! no-child scenario would need extra out-of-band process teardown in the +//! test harness. The CA-export code path now runs unconditionally before the +//! child-spawn branch (see `run_proxy` in src/commands/run.rs), so this still +//! fully exercises both the bind-address change and the CA-export logic that +//! a true standalone/Docker-sidecar run would use. The fully standalone case +//! is verified manually via examples/docker-sidecar/. +//! +//! Requirements: `curl` on PATH. +//! Run: `cargo test --test proxy_bind_integration` + +use std::io::{Read, Write as IoWrite}; +use std::net::{TcpListener, TcpStream}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers (mirrors proxy_php_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"}"#; + +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 _ = String::from_utf8_lossy(&buf[..n]); + write_response(stream, "200 OK", HEALTH_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, + } + } + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_bind_all_interfaces_and_ca_export() { + if Command::new("curl") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_err() + { + eprintln!("SKIP: `curl` not found"); + return; + } + + let phantom_bin = env!("CARGO_BIN_EXE_phantom"); + let tmp_dir = tempfile::tempdir().expect("tempdir"); + + let http_port = available_port(); + let proxy_port = available_port(); + + let _http_thread = start_http_backend(http_port); + assert!( + wait_for_port(http_port, Duration::from_secs(3)), + "HTTP backend did not start" + ); + + let phantom_output = Command::new(phantom_bin) + .args([ + "run", + "--backend", + "proxy", + "--bind", + "0.0.0.0", + "--output", + "jsonl", + "--port", + &proxy_port.to_string(), + "--data-dir", + ]) + .arg(tmp_dir.path()) + .arg("--") + .arg("curl") + .arg("-s") + .arg("-o") + .arg("/dev/null") + .arg(format!("http://127.0.0.1:{http_port}/api/health")) + .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 — proves the proxy bound to 0.0.0.0 still works ── + let traces: Vec = stdout_buf + .lines() + .filter(|l| l.starts_with('{')) + .filter_map(|l| serde_json::from_str(l).ok()) + .filter(|v: &serde_json::Value| v.get("trace_id").is_some()) + .collect(); + + assert_eq!( + traces.len(), + 1, + "Expected 1 trace, got {}.\n stdout:\n{stdout_buf}\n stderr:\n{stderr_buf}", + traces.len(), + ); + assert_eq!(traces[0]["method"], "GET"); + assert_eq!(traces[0]["status_code"], 200); + assert!( + traces[0]["response_body"] + .as_str() + .is_some_and(|b| b.contains("ok")) + ); + + // ── /ca.pem must exist and be PEM-encoded ───────────────────── + let ca_path = tmp_dir.path().join("ca.pem"); + let ca_pem = std::fs::read_to_string(&ca_path) + .unwrap_or_else(|e| panic!("ca.pem should exist at {ca_path:?}: {e}")); + assert!( + ca_pem.contains("BEGIN CERTIFICATE"), + "ca.pem should be PEM-encoded, got:\n{ca_pem}" + ); + + eprintln!("bind=0.0.0.0 proxy verified, ca.pem exported to {ca_path:?}"); +}