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:?}"); +}