From 76eb0576ca4e2ca7706edee1d2f67dcd25053c17 Mon Sep 17 00:00:00 2001 From: Flaykz Date: Tue, 7 Jul 2026 17:19:13 +1100 Subject: [PATCH 1/8] WIP --- .claude/settings.json | 14 + .github/ISSUE_TEMPLATE/bug_report.yml | 8 +- .github/workflows/ci.yml | 47 +- AGENTS.md | 9 +- CONTRIBUTING.md | 27 +- Cargo.lock | 567 +---- Cargo.toml | 8 +- README.md | 238 +- SECURITY.md | 2 +- apps/desktop/index.html | 2 +- apps/desktop/package.json | 3 +- apps/desktop/src-tauri/Cargo.toml | 11 +- apps/desktop/src-tauri/build.rs | 14 - apps/desktop/src-tauri/src/main.rs | 1015 +++++++- apps/desktop/src-tauri/tauri.conf.json | 8 +- apps/desktop/src/App.tsx | 2165 +++++++++++++++-- apps/desktop/src/styles.css | 1367 +++++++++-- crates/capture/src/lib.rs | 375 +++ crates/certs/Cargo.toml | 12 - crates/certs/src/lib.rs | 289 --- .../Cargo.toml | 6 +- crates/fault_rules/src/lib.rs | 119 + crates/java_processes/src/lib.rs | 120 - crates/{proxy => mitm_engine}/Cargo.toml | 19 +- crates/mitm_engine/src/bin/spike.rs | 45 + crates/mitm_engine/src/bridge_addon.py | 420 ++++ crates/mitm_engine/src/lib.rs | 647 +++++ crates/mitm_engine/src/trust.rs | 213 ++ crates/processes/Cargo.toml | 16 + crates/processes/src/lib.rs | 231 ++ crates/processes/src/window_titles.rs | 56 + crates/proxy/src/lib.rs | 1091 --------- crates/storage/Cargo.toml | 1 + crates/storage/src/lib.rs | 356 ++- docs/live-streaming-bodies.md | 127 + docs/mvp.md | 103 +- docs/spring-boot-proxy.md | 161 +- java/README.md | 32 - java/agent/MANIFEST.MF | 5 - java/agent/agent.iml | 11 - .../agent/OpenInterceptAgent.java | 143 -- java/attach-helper/MANIFEST.MF | 2 - java/attach-helper/attach-helper.iml | 11 - .../openintercept/attach/AttachHelper.java | 27 - package-lock.json | 14 +- package.json | 3 +- scripts/build-java.mjs | 11 - scripts/build-java.ps1 | 23 - scripts/build-java.sh | 36 - scripts/create-java-truststore.ps1 | 56 - scripts/create-java-truststore.sh | 85 - scripts/manage-jdk-truststore.ps1 | 104 +- scripts/manage-jdk-truststore.sh | 115 +- scripts/set-version.mjs | 4 +- scripts/timing-test-server.mjs | 150 ++ 55 files changed, 7448 insertions(+), 3296 deletions(-) create mode 100644 .claude/settings.json delete mode 100644 crates/certs/Cargo.toml delete mode 100644 crates/certs/src/lib.rs rename crates/{java_processes => fault_rules}/Cargo.toml (62%) create mode 100644 crates/fault_rules/src/lib.rs delete mode 100644 crates/java_processes/src/lib.rs rename crates/{proxy => mitm_engine}/Cargo.toml (52%) create mode 100644 crates/mitm_engine/src/bin/spike.rs create mode 100644 crates/mitm_engine/src/bridge_addon.py create mode 100644 crates/mitm_engine/src/lib.rs create mode 100644 crates/mitm_engine/src/trust.rs create mode 100644 crates/processes/Cargo.toml create mode 100644 crates/processes/src/lib.rs create mode 100644 crates/processes/src/window_titles.rs delete mode 100644 crates/proxy/src/lib.rs create mode 100644 docs/live-streaming-bodies.md delete mode 100644 java/README.md delete mode 100644 java/agent/MANIFEST.MF delete mode 100644 java/agent/agent.iml delete mode 100644 java/agent/src/dev/openintercept/agent/OpenInterceptAgent.java delete mode 100644 java/attach-helper/MANIFEST.MF delete mode 100644 java/attach-helper/attach-helper.iml delete mode 100644 java/attach-helper/src/dev/openintercept/attach/AttachHelper.java delete mode 100644 scripts/build-java.mjs delete mode 100644 scripts/build-java.ps1 delete mode 100755 scripts/build-java.sh delete mode 100644 scripts/create-java-truststore.ps1 delete mode 100755 scripts/create-java-truststore.sh create mode 100644 scripts/timing-test-server.mjs diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..55b2b8d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "allow": [ + "Bash(tasklist *)", + "Bash(cargo check *)", + "Bash(cargo build *)", + "Bash(cargo fmt --all -- --check)", + "Bash(./gradlew compileJava *)", + "Bash(npx tsc *)", + "Bash(npm run typecheck)", + "Bash(where *)" + ] + } +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 58b4ec2..4ec89d4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ name: Bug report -description: Report a reproducible problem in OpenIntercept +description: Report a reproducible problem in ProcTap title: "[Bug] " labels: - bug @@ -37,7 +37,7 @@ body: id: environment attributes: label: Environment - description: OS, OpenIntercept version or commit, Rust/Node/JDK version if relevant + description: OS, ProcTap version or commit, Rust/Node/JDK version if relevant placeholder: Windows 11, main@abc123, Temurin 21... validations: required: true @@ -46,7 +46,7 @@ body: attributes: label: Steps to reproduce placeholder: | - 1. Start OpenIntercept with... + 1. Start ProcTap with... 2. Configure target app with... 3. Trigger request to... 4. Observe... @@ -68,7 +68,7 @@ body: id: proxy-config attributes: label: Proxy and trust configuration - description: Explain how the target app was configured to use the proxy and trust the OpenIntercept CA + description: Explain how the target app was configured to use the proxy and trust the ProcTap CA validations: required: false - type: textarea diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 665cabd..47e7a41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,12 +47,6 @@ jobs: - name: Cache Rust builds uses: Swatinem/rust-cache@v2 - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: 21 - - name: Install npm dependencies run: npm ci @@ -80,12 +74,12 @@ jobs: include: - name: windows-x64 runner: windows-latest - binary: target/release/openintercept-desktop.exe - artifact: openintercept-windows-x64 + binary: target/release/proctap-desktop.exe + artifact: proctap-windows-x64 - name: ubuntu-x64 runner: ubuntu-22.04 - binary: target/release/openintercept-desktop - artifact: openintercept-ubuntu-x64 + binary: target/release/proctap-desktop + artifact: proctap-ubuntu-x64 steps: - name: Checkout @@ -118,12 +112,6 @@ jobs: - name: Cache Rust builds uses: Swatinem/rust-cache@v2 - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: 21 - - name: Install npm dependencies run: npm ci @@ -135,10 +123,7 @@ jobs: with: name: ${{ matrix.artifact }} if-no-files-found: error - path: | - ${{ matrix.binary }} - java/build/openintercept-agent.jar - java/build/openintercept-attach-helper.jar + path: ${{ matrix.binary }} release-plan: name: Release plan @@ -188,13 +173,13 @@ jobs: include: - name: windows-x64 runner: windows-latest - binary: target/release/openintercept-desktop.exe - archive: openintercept-v${{ needs.release-plan.outputs.version }}-windows-x64.zip + binary: target/release/proctap-desktop.exe + archive: proctap-v${{ needs.release-plan.outputs.version }}-windows-x64.zip artifact: release-asset-windows-x64 - name: ubuntu-x64 runner: ubuntu-22.04 - binary: target/release/openintercept-desktop - archive: openintercept-v${{ needs.release-plan.outputs.version }}-ubuntu-x64.tar.gz + binary: target/release/proctap-desktop + archive: proctap-v${{ needs.release-plan.outputs.version }}-ubuntu-x64.tar.gz artifact: release-asset-ubuntu-x64 steps: @@ -228,12 +213,6 @@ jobs: - name: Cache Rust builds uses: Swatinem/rust-cache@v2 - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: 21 - - name: Install npm dependencies run: npm ci @@ -248,18 +227,14 @@ jobs: shell: pwsh run: | New-Item -ItemType Directory -Path "release-dist" - Copy-Item -LiteralPath "${{ matrix.binary }}" -Destination "release-dist/OpenIntercept.exe" - Copy-Item -LiteralPath "java/build/openintercept-agent.jar" -Destination "release-dist/openintercept-agent.jar" - Copy-Item -LiteralPath "java/build/openintercept-attach-helper.jar" -Destination "release-dist/openintercept-attach-helper.jar" + Copy-Item -LiteralPath "${{ matrix.binary }}" -Destination "release-dist/ProcTap.exe" Compress-Archive -Path "release-dist/*" -DestinationPath "${{ matrix.archive }}" - name: Package Ubuntu asset if: runner.os == 'Linux' run: | mkdir release-dist - cp "${{ matrix.binary }}" "release-dist/openintercept-desktop" - cp java/build/openintercept-agent.jar release-dist/openintercept-agent.jar - cp java/build/openintercept-attach-helper.jar release-dist/openintercept-attach-helper.jar + cp "${{ matrix.binary }}" "release-dist/proctap-desktop" tar -czf "${{ matrix.archive }}" -C release-dist . - name: Upload release asset artifact diff --git a/AGENTS.md b/AGENTS.md index c7c2ab6..9383c33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,10 @@ -# OpenIntercept Conventions +# ProcTap Conventions -- Keep the MVP focused on a read-only local HTTP/HTTPS proxy with the most automatic HTTPS certificate setup possible. -- Treat JVM traffic inspection as an important follow-up workflow built on top of the proxy, not as the primary product identity. +- Keep the MVP focused on read-only, per-process HTTP/HTTPS capture built on top of mitmproxy (`mitmdump --mode local:` run as an external engine subprocess in `crates/mitm_engine`), with the most automatic HTTPS trust setup possible. +- ProcTap does not implement its own MITM proxy engine. Do not reintroduce one; extend `crates/mitm_engine` and `bridge_addon.py` instead. +- Treat JVM trust automation as an important follow-up workflow built on top of capture, not as the primary product identity. - Prefer small, isolated Rust crates with explicit boundaries. -- Do not add traffic modification, replay, mocking, or mobile support until the MVP is stable. +- Do not add replay, general-purpose mocking, or mobile support until the MVP is stable. The one exception: scoped, rule-based fault injection (delay, forced status/body, abort, throttle) on request/response, applied via `bridge_addon.py` and exposed through both Tauri commands and a desktop UI panel, per `README.md` roadmap P8. - Keep the desktop app local-first: no cloud service, account, or telemetry. - Use ASCII for code and documentation unless a file already uses non-ASCII. - Treat `README.md` as the project source of truth for implemented features, current work, roadmap, and known limitations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76ea8ab..8a38249 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,16 +1,16 @@ -# Contributing to OpenIntercept +# Contributing to ProcTap Thanks for contributing. -This project aims to be a focused, professional open-source desktop proxy for local HTTP and HTTPS inspection. The current priority is a solid read-only MVP, not a broad traffic manipulation platform. +This project aims to be a focused, professional open-source desktop tool that makes mitmproxy's per-process capture mode easy to use for local HTTP and HTTPS inspection. The current priority is a solid read-only MVP, not a broad traffic manipulation platform. ## Product Scope Please align contributions with the current project direction: -- keep the MVP focused on a read-only local HTTP/HTTPS proxy; +- keep the MVP focused on read-only, per-process HTTP/HTTPS capture built on top of mitmproxy; - prefer improvements to reliability, clarity, and local developer experience; -- treat JVM interception as an important follow-up workflow, not the core product identity; +- treat JVM trust automation as an important follow-up workflow, not the core product identity; - do not introduce rewrite, replay, mocking, mutation, or unrelated protocol integrations unless explicitly discussed first. The source of truth for current product state is `README.md`. @@ -28,7 +28,8 @@ Install: - Rust - Node.js - Tauri prerequisites for your platform -- a JDK if you work on the Java helper flow +- mitmproxy (provides the `mitmdump` binary the desktop app spawns) +- a JDK if you work on the JVM trust automation flow From the repository root: @@ -59,18 +60,6 @@ npm run typecheck npm run build ``` -If you change the Java helper workflow, also rebuild the Java artifacts: - -```powershell -./scripts/build-java.ps1 -``` - -or: - -```bash -./scripts/build-java.sh -``` - ## Pull Request Expectations Keep pull requests focused and easy to review. @@ -104,7 +93,7 @@ Open an issue first when: Bug reports are most useful when they include: - platform and OS version; -- how OpenIntercept was started; -- how the target application was configured to use the proxy; +- how ProcTap was started, and whether mitmproxy was installed and on `PATH`; +- which process was targeted for capture; - whether the issue is HTTP-only, HTTPS trust-related, or JVM-specific; - reproduction steps, logs, and screenshots when available. diff --git a/Cargo.lock b/Cargo.lock index bf08a6b..cd2e4e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,45 +68,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "asn1-rs" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6fd5ddaf0351dff5b8da21b2fb4ff8e08ddd02857f0bf69c47639106c0fff0" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror 1.0.69", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "726535892e8eae7e70657b4c8ea93d26b8553afb1ce617caee529ef96d7dee6c" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", - "synstructure 0.12.6", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2777730b2039ac0f95f093556e61b6d26cebed5393ca6f152717777cec3a42ed" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "atk" version = "0.18.2" @@ -142,28 +103,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "aws-lc-rs" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "backtrace" version = "0.3.76" @@ -239,17 +178,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "brotli" -version = "7.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor 4.0.3", -] - [[package]] name = "brotli" version = "8.0.4" @@ -258,17 +186,7 @@ checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", - "brotli-decompressor 5.0.3", -] - -[[package]] -name = "brotli-decompressor" -version = "4.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "brotli-decompressor", ] [[package]] @@ -399,22 +317,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] -[[package]] -name = "certs" -version = "0.1.0" -dependencies = [ - "anyhow", - "dirs", - "rcgen", - "serde", - "sha2", -] - [[package]] name = "cesu8" version = "1.1.0" @@ -460,15 +365,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "combine" version = "4.6.7" @@ -664,12 +560,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - [[package]] name = "dbus" version = "0.9.11" @@ -681,20 +571,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "der-parser" -version = "8.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbd676fbbab537128ef0278adb5576cf363cff6aa22a7b24effe97347cfab61e" -dependencies = [ - "asn1-rs", - "displaydoc", - "nom", - "num-bigint", - "num-traits", - "rusticata-macros", -] - [[package]] name = "deranged" version = "0.5.8" @@ -939,6 +815,15 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fault_rules" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "uuid", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -1028,12 +913,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futures-channel" version = "0.3.32" @@ -1461,6 +1340,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -1746,15 +1634,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "java_processes" -version = "0.1.0" -dependencies = [ - "anyhow", - "serde", - "sysinfo", -] - [[package]] name = "javascriptcore-rs" version = "1.1.2" @@ -1822,16 +1701,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.102" @@ -1876,12 +1745,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -1957,6 +1820,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "litemap" version = "0.8.2" @@ -2010,12 +1879,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2037,6 +1900,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mitm_engine" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "capture", + "fault_rules", + "serde", + "serde_json", + "sha2", + "tokio", + "uuid", + "which", +] + [[package]] name = "muda" version = "0.19.2" @@ -2088,16 +1967,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "ntapi" version = "0.4.3" @@ -2107,31 +1976,12 @@ dependencies = [ "winapi", ] -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -2367,42 +2217,12 @@ dependencies = [ "memchr", ] -[[package]] -name = "oid-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bedf36ffb6ba96c2eb7144ef6270557b52e54b20c0a8e1eb2ff99a6c6959bff" -dependencies = [ - "asn1-rs", -] - [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "openintercept-desktop" -version = "0.1.0" -dependencies = [ - "capture", - "certs", - "java_processes", - "proxy", - "serde", - "storage", - "tauri", - "tauri-build", - "tokio", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - [[package]] name = "option-ext" version = "0.2.0" @@ -2457,16 +2277,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64 0.22.1", - "serde_core", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -2671,22 +2481,31 @@ dependencies = [ ] [[package]] -name = "proxy" +name = "processes" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde", + "sysinfo", + "windows 0.61.3", +] + +[[package]] +name = "proctap-desktop" version = "0.1.0" dependencies = [ "anyhow", - "brotli 7.0.0", "capture", - "certs", - "flate2", - "rustls", - "rustls-native-certs", - "rustls-pemfile", + "dirs", + "fault_rules", + "mitm_engine", + "processes", "serde", + "storage", + "tauri", + "tauri-build", "tokio", - "tokio-rustls", "uuid", - "webpki-roots 0.26.11", ] [[package]] @@ -2745,19 +2564,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "rcgen" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48406db8ac1f3cbc7dcdb56ec355343817958a356ff430259bb07baf7607e1e1" -dependencies = [ - "pem", - "ring 0.17.14", - "time", - "x509-parser", - "yasna", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -2861,35 +2667,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "ring" -version = "0.16.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" -dependencies = [ - "cc", - "libc", - "once_cell", - "spin", - "untrusted 0.7.1", - "web-sys", - "winapi", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted 0.9.0", - "windows-sys 0.52.0", -] - [[package]] name = "rusqlite" version = "0.32.1" @@ -2926,69 +2703,16 @@ dependencies = [ ] [[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom", -] - -[[package]] -name = "rustls" -version = "0.23.40" +name = "rustix" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "aws-lc-rs", - "log", - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" -dependencies = [ - "openssl-probe", - "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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "aws-lc-rs", - "ring 0.17.14", - "rustls-pki-types", - "untrusted 0.9.0", + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", ] [[package]] @@ -3006,15 +2730,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "schemars" version = "0.8.22" @@ -3072,29 +2787,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.13.0", - "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 = "selectors" version = "0.36.1" @@ -3401,12 +3093,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3419,6 +3105,7 @@ version = "0.1.0" dependencies = [ "anyhow", "capture", + "fault_rules", "rusqlite", "serde_json", "uuid", @@ -3454,12 +3141,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "swift-rs" version = "1.0.7" @@ -3478,7 +3159,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", - "quote", "unicode-ident", ] @@ -3502,18 +3182,6 @@ dependencies = [ "futures-core", ] -[[package]] -name = "synstructure" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", - "unicode-xid", -] - [[package]] name = "synstructure" version = "0.13.2" @@ -3689,7 +3357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" dependencies = [ "base64 0.22.1", - "brotli 8.0.4", + "brotli", "ico", "json-patch", "plist", @@ -3781,7 +3449,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" dependencies = [ "anyhow", - "brotli 8.0.4", + "brotli", "cargo_metadata", "ctor", "dom_query", @@ -3957,16 +3625,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - [[package]] name = "tokio-util" version = "0.7.17" @@ -4263,18 +3921,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - [[package]] name = "url" version = "2.5.8" @@ -4573,24 +4219,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.7", -] - -[[package]] -name = "webpki-roots" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webview2-com" version = "0.38.2" @@ -4627,6 +4255,18 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix", + "winsafe", +] + [[package]] name = "winapi" version = "0.3.9" @@ -5040,6 +4680,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -5205,33 +4851,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "x509-parser" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7069fba5b66b9193bd2c5d3d4ff12b839118f6bcbef5328efafafb5395cf63da" -dependencies = [ - "asn1-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom", - "oid-registry", - "ring 0.16.20", - "rusticata-macros", - "thiserror 1.0.69", - "time", -] - -[[package]] -name = "yasna" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" -dependencies = [ - "time", -] - [[package]] name = "yoke" version = "0.8.3" @@ -5252,7 +4871,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "synstructure 0.13.2", + "synstructure", ] [[package]] @@ -5293,15 +4912,9 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "synstructure 0.13.2", + "synstructure", ] -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index 2d6792a..9c3e25f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,16 +3,16 @@ resolver = "2" members = [ "apps/desktop/src-tauri", "crates/capture", - "crates/certs", - "crates/java_processes", - "crates/proxy", + "crates/fault_rules", + "crates/mitm_engine", + "crates/processes", "crates/storage" ] [workspace.package] edition = "2021" license = "MIT" -repository = "https://github.com/Flaykz/OpenIntercept" +repository = "https://github.com/Flaykz/ProcTap" [workspace.dependencies] anyhow = "1.0" diff --git a/README.md b/README.md index 6412547..a2999dd 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ -# OpenIntercept +# ProcTap -OpenIntercept is a work-in-progress local-first desktop tool for intercepting and inspecting HTTP and HTTPS traffic during development. +ProcTap is a work-in-progress local-first desktop tool for capturing and inspecting HTTP and HTTPS traffic from a chosen process during development — without modifying that process's configuration. -The project is not functional yet. It is under active development and should not be used as a working proxy until the MVP is stabilized. +The project is not functional yet. It is under active development and should not be relied on for real inspection workflows until the MVP is stabilized. -The product goal is intentionally narrow: provide a professional, read-only local proxy that is easy to run, easy to trust locally, and easy to contribute to. +The product goal is intentionally narrow: make [mitmproxy](https://mitmproxy.org/)'s per-process capture mode (`mitmdump --mode local:`) easy to run, easy to trust locally, and easy to contribute to — a friendly desktop shell around an engine that already solves the hard parts of HTTP/HTTPS interception. The current MVP focuses on: -- starting a local HTTP/HTTPS proxy for development traffic; -- generating and reusing a local CA and per-host certificates for HTTPS MITM; -- making local HTTPS trust setup as automatic as possible for common development workflows; +- picking a running process by name and capturing its traffic without touching that process's proxy settings, environment, or startup flags; +- an optional, independent explicit proxy mode (toggle + port) for the rare case where a target can't be picked by process name/PID at all (containers, remote hosts) — the client must be configured to use it, but it works for any client; +- running `mitmdump` as an external engine subprocess (ProcTap does not implement its own MITM proxy); +- installing mitmdump's own CA into the OS trust store, and into JDK truststores for JVM targets, automatically; - displaying captured exchanges in a local desktop UI; -- keeping JVM interception as a follow-up workflow when the target app is a Java process; -- keeping capture read-only: no rewrite, replay, mocking, or mutation. +- keeping capture read-only by default: no rewrite, replay, or mocking; the only exception is scoped, opt-in fault injection rules (delay, forced error, abort, throttle) for application testing, currently engine-only with no UI (see Roadmap P8). ## Project Menu @@ -29,27 +29,27 @@ The current MVP focuses on: - [Development](#development) - [CI/CD](#cicd) - [Contributing](#contributing) -- [Spring Boot HTTPS Debugging](#spring-boot-https-debugging) +- [Spring Boot / JVM Capture](#spring-boot--jvm-capture) - [License](#license) ## Status -OpenIntercept is in active MVP development and is not functional yet. +ProcTap is in active MVP development and is not functional yet. -- The desktop application, local proxy, and HTTPS MITM flow are still being integrated and stabilized. +- The desktop application and the mitmproxy-subprocess capture pipeline are still being integrated and stabilized. - The current codebase may build or run partially, but the end-to-end product is not ready for real inspection workflows. -- JVM interception exists as a secondary workflow and is still less complete than the core proxy path. -- The project is not yet positioned as a general-purpose production proxy. +- JVM trust automation exists but is less battle-tested than the core per-process capture path. +- The project is not yet positioned as a general-purpose production tool. ## Quick Start ### 1. Install prerequisites -Install Node.js, Rust, and the Tauri prerequisites for your platform. +Install Node.js, Rust, the Tauri prerequisites for your platform, and mitmproxy. -- Windows: [Tauri prerequisites](https://tauri.app/start/prerequisites/), [Node.js](https://nodejs.org), and a JDK such as [Eclipse Temurin](https://adoptium.net). +- Windows: [Tauri prerequisites](https://tauri.app/start/prerequisites/), [Node.js](https://nodejs.org), [mitmproxy](https://mitmproxy.org/) (provides `mitmdump.exe`), and a JDK such as [Eclipse Temurin](https://adoptium.net) if you want the JVM trust workflow. - Linux: see the package list in [Prerequisites](#prerequisites). -- macOS: install the standard [Tauri prerequisites](https://tauri.app/start/prerequisites/), Node.js, Rust, and a JDK if you want the JVM workflow. +- macOS: install the standard [Tauri prerequisites](https://tauri.app/start/prerequisites/), Node.js, Rust, mitmproxy, and a JDK if you want the JVM trust workflow. ### 2. Install dependencies @@ -63,16 +63,19 @@ npm install npm run dev ``` -This launches the Tauri desktop app and starts the local proxy on `127.0.0.1:8877`. +This launches the Tauri desktop app. No proxy is started automatically — capture is per-process and starts only when you pick a target. -### 4. Route traffic through OpenIntercept +### 4. Pick a process to capture -Configure the application you want to inspect to use: +In the desktop UI, type the name of the process you want to inspect (e.g. `curl.exe`, `java`) and click **Start capture**. ProcTap spawns `mitmdump --mode local:`, which redirects only that process's traffic at the OS level — no proxy configuration on the target application is needed. -- HTTP proxy: `127.0.0.1:8877` -- HTTPS proxy: `127.0.0.1:8877` +For HTTPS, ProcTap automatically installs mitmdump's CA into the OS trust store on first capture, and into JDK truststores if the target looks like a JVM process. -For HTTPS interception, the target client must trust the OpenIntercept local CA. The repository includes helper scripts for JVM truststores, and the desktop app generates the CA automatically on first run. +### 4b. Or: capture a client that can't be picked by process (explicit proxy) + +If the app you want to inspect can't be targeted by process name/PID at all — it runs in a container, on another host, or some other way the OS-level redirector can't reach — flip the **Explicit proxy** toggle next to the process picker and pick a port (default `8877`). This starts a second, independent `mitmdump` session listening as a standing HTTP(S) proxy on that port; point the client's proxy settings at `127.0.0.1:` (or the machine's LAN address, for a remote client) and its traffic appears in the same traffic list, tagged `External client` instead of a process name since there's no process to attribute it to. + +This mode runs **concurrently** with, not instead of, the process picker above — you can have both a picker-based capture and the explicit proxy running at the same time, each independently started/stopped. ### 5. Inspect captured exchanges @@ -81,105 +84,109 @@ Use the desktop UI to: - inspect requests and responses; - search by method, status, URL, headers, and body text; - export captures as JSON; -- clear persisted and in-memory traffic. +- clear persisted and in-memory traffic, except pinned exchanges. ## Usage ### Standard local workflow -1. Start OpenIntercept with `npm run dev`. -2. Trigger the app once so the local CA is generated. -3. Configure your target application to use `127.0.0.1:8877` as its HTTP and HTTPS proxy. -4. Make the target trust the OpenIntercept CA when HTTPS MITM is required. -5. Reproduce the HTTP or HTTPS traffic you want to inspect. -6. Review the captures in the desktop UI. +1. Start ProcTap with `npm run dev`. +2. Run the application you want to inspect (no special startup flags needed). +3. In ProcTap, enter that process's name and click **Start capture**. +4. Reproduce the HTTP or HTTPS traffic you want to inspect. +5. Review the captures in the desktop UI. ### When HTTPS traffic does not appear Check the following first: -- the target application is really using the proxy; -- the target trusts the generated OpenIntercept CA; +- `mitmdump` is actually running (ProcTap reports an error if it can't find it on `PATH`); +- the process name you entered matches the actual running process; +- the client was started (or restarted) after ProcTap's CA was installed — clients that already loaded their TLS state before the CA import won't pick it up until restarted; - the client is not using certificate pinning; - the client is not forcing HTTP/2 or a custom TLS stack outside the currently supported path. -For Java and Spring Boot workflows, see [Spring Boot HTTPS Debugging](#spring-boot-https-debugging). +For Java and Spring Boot workflows, see [Spring Boot / JVM Capture](#spring-boot--jvm-capture). ## Features - Local-first desktop application built with Tauri and React. -- Read-only HTTP/HTTPS inspection through a local Rust proxy on `127.0.0.1:8877`. -- Local OpenIntercept CA generation and dynamic per-host certificate generation for HTTPS MITM. -- Truststore helper scripts for Windows, Linux, and macOS-style shells to speed up HTTPS setup in local development. -- Explicit non-attach proxy workflow for local applications that can be started with proxy and trust options. -- Java process discovery from the desktop UI. -- Java attach helper and agent that inject JVM proxy properties for selected processes. -- Java/Spring Boot workflow for routing JVM traffic through the proxy when useful. -- HTTP capture with request headers, request body, response headers, response body, status, duration, and errors. -- Robust response reading for `Content-Length`, `Transfer-Encoding: chunked`, and EOF fallback responses. +- Read-only per-process HTTP/HTTPS capture via mitmproxy (`mitmdump --mode local:`) run as an external engine subprocess — no target-app configuration required. +- Process picker listing all running processes, tagging likely JVM processes. +- Process picker disambiguates same-named processes with PID, visible window/JVM labels when available, and a short parent-process chain to help identify shells launched from IntelliJ, Windows Terminal, classic consoles, or scripts. +- Process picker supports selecting several same-named process instances before clicking `Add capture`, so multiple PIDs can be added in one action. +- JVM process display labels prefer the launched `.jar`/`.war` name when it can be inferred from the command line, falling back to a JVM main class for IDE-launched apps. +- Capture multiple processes at once: add as many concurrent per-process sessions as you want, stop any one independently, and ProcTap runs one combined mitmproxy local redirector (`local:,`) for all active picker captures. +- Optional, independent explicit-proxy mode (toggleable, port-configurable) that runs concurrently with per-process capture, for clients that can't be targeted by process name/PID at all. +- One-click launch of a dedicated Chromium browser profile through the explicit proxy, including loopback traffic such as `localhost`. +- Automatic CA trust installation into the current user's OS trust store on first capture (for both per-process and explicit-proxy sessions). +- Automatic CA trust installation into every discovered JDK truststore when the capture target is a JVM process, or always when the explicit proxy is started (any client could connect to it). +- Upstream TLS certificate verification is disabled in mitmproxy so internal services signed by private/corporate CAs can still be captured. +- HTTP capture with request headers, request body, response headers, response body, status, duration, and errors. Non-streaming request/response bodies are capped before they cross the mitmproxy bridge to keep the desktop app responsive under bursty browser traffic. +- Live SSE response-body capture: `text/event-stream` responses appear as soon as headers arrive, append chunks while the stream is open, are marked `live` in the UI, and persist periodically to SQLite during reception. - Captured response body decoding for chunked, `gzip`, `deflate`, and `br` encoded responses. -- Traffic list with method/status filters and text search across URL, method, status, request headers, response headers, request body, and response body. +- Traffic list with method/status filters, an XHR/Fetch-style display toggle that hides static frontend resources, and text search across URL, method, status, request headers, and response headers. The live list polls body-free summaries for responsiveness; full bodies are loaded on demand when an exchange is selected or exported. +- Traffic row context menu for hiding/ignoring hosts and seeding a fault-rule draft from the selected exchange's host and path. +- Per-exchange pin/delete actions; pinned exchanges stay visible at the top of the traffic list and survive `Clear traffic`. - Exchange detail tabs: `Overview`, `Request`, `Response`, and `Raw`. -- Failed HTTPS MITM captures show phase, likely cause, suggested action, host, and raw technical error in the UI. - JSON export for the selected capture or current filtered capture set. -- `Clear traffic` action that clears in-memory and persisted captures. +- `Clear traffic` action that clears unpinned in-memory and persisted captures. - SQLite persistence for captures with a bounded retention window. +- Fault injection (delay, forced status/body, abort, throttle, streamed-response disconnect) matched by host/method/path/header, optionally applied to a percentage of matching traffic, persisted and broadcast live to every running capture session, with a desktop UI panel to create/toggle/delete rules (see Roadmap P8). +- Live backend log panel showing recent in-memory ProcTap and `mitmdump` diagnostics, with search and clear actions. ## Roadmap Roadmap state legend: `[ ]` not started, `[~]` in progress, `[x]` completed. -### P0 - HTTPS Proxy Core +### P0 - Capture Core -- [x] Desktop shell with proxy status and traffic list. -- [x] Local proxy startup on `127.0.0.1:8877`. -- [x] Basic HTTP request/response capture. -- [x] First-pass HTTPS MITM with local CA and dynamic host certificates. -- [x] Reuse the local CA and generated host certificates across sessions. +- [x] Desktop shell with process picker and traffic list. +- [x] `crates/mitm_engine`: spawn `mitmdump --mode local:` and receive flow JSON over a local bridge socket. +- [x] Map incoming flows into the existing `CapturedExchange` / SQLite pipeline. - [x] Decode captured compressed bodies: `gzip`, `deflate`, and `br`. -- [x] Support robust HTTP response reading for `Content-Length` and `Transfer-Encoding: chunked`. - [x] Add `Clear traffic` in the UI and backend. -- [x] Validate end-to-end HTTPS capture against a real local Java/Spring Boot app. +- [x] Add a toggleable, port-configurable explicit proxy (`mitmdump --mode regular@`) running concurrently with per-process capture, for clients that can't be targeted by process name/PID at all — traffic from it is tagged `External client` since there's no process to attribute it to. +- [x] Support multiple concurrent per-process capture sessions (add/stop each independently) by running one combined mitmproxy local redirector with a comma-separated intercept spec instead of one `mitmdump --mode local:` subprocess per picker session. This avoids the Windows multi-redirector conflict while keeping the explicit-proxy mode independent. -### P1 - HTTPS Setup Automation +### P1 - HTTPS Trust Automation -- [x] Provide JDK truststore management scripts. -- [x] Provide a generated Java truststore for explicit JVM startup flows. -- [ ] Add a clearer in-app HTTPS setup guide for the current platform. +- [x] Install mitmdump's CA into the current user's OS trust store automatically on first capture. +- [x] Install mitmdump's CA into every discovered JDK truststore automatically for JVM targets. +- [ ] Add a clearer in-app HTTPS trust status guide for the current platform. - [ ] Detect common trust/setup failures and surface the next action directly in the UI. -- [ ] Reduce manual certificate steps for the common local proxy workflow as much as platform constraints allow. ### P2 - Inspection Usability - [x] Persist captures to SQLite with a size limit. - [x] Add filters/search by method, status, URL, and response body text. - [x] Add exchange detail tabs: `Overview`, `Request`, `Response`, `Raw`. -- [x] Improve failed HTTPS MITM captures so diagnosis is clear in the UI. - [x] Extend search to request headers, response headers, and request body text. - [x] Add export for selected capture or current filtered capture set. +- [x] Add an XHR/Fetch-style display filter to hide static frontend resources while inspecting app traffic. +- [x] Reduce traffic filter clutter by summarizing hidden/ignored filter counts by default, with the full removable list available on demand. ### P3 - Inspection Depth -- [ ] Add pause/resume capture without stopping the proxy. +- [ ] Add pause/resume capture without stopping the session. - [ ] Add structured filters: `method=`, `status=`, `host=`, `path*=`, `header[name]*=`, `body*=`, and `bodySize`. - [ ] Display URL parts separately: protocol, host, port, path, and query parameters. -- [ ] Add visual capture categories: error, mutative, JSON, HTML, image, binary, and unknown. -- [ ] Add per-capture pin and delete actions. +- [ ] Add visual capture categories: error, JSON, HTML, image, binary, and unknown. +- [x] Add pin/delete actions for individual captured exchanges. - [ ] Add body viewers by MIME type: JSON, XML, HTML, form data, multipart, text, hex, image preview, and binary fallback. -### P4 - JVM Interception +### P4 - Process Attribution -- [x] Build Java agent and attach helper artifacts. -- [x] Attach to a selected Java PID and inject proxy properties. -- [ ] Improve Java attach by injecting OpenIntercept CA trust dynamically into the target JVM. -- [ ] Package Java helper artifacts reliably into the Tauri app bundle for release builds. -- [ ] Add process attribution so captures can be linked to a Java PID when possible. +- [x] Attribute captures to a specific PID, not just a process name, when mitmproxy exposes that information. Investigated: mitmproxy's own flow objects (`flow.client_conn`) never expose the originating PID — verified directly against a live capture (mitmproxy 11.1.3 / mitmproxy_rs 0.11.5) — even though the underlying native `Stream` supports it, mitmproxy's core proxy layer never reads it into the connection object addons see. `local:` does accept a literal numeric PID as its target, though, so the picker can scope mitmdump itself to one exact process instance; ProcTap then stamps every exchange from that session with the PID it already asked for. +- [x] Surface multiple same-named processes distinctly in the picker. When more than one running process shares the typed name, the picker shows a PID chip per instance so a specific one can be selected before starting capture. +- [x] Show a short parent-process chain in PID choices to help distinguish same-named shells and tools by launcher. +- [x] Allow multi-selecting same-named PID choices before adding captures. +- [x] Prefer a JVM `.jar`/`.war` artifact name over a bare PID in process-instance display when it can be inferred from the process command line, falling back to the JVM main class for IDE-launched apps without a packaged artifact. ### P5 - Protocol Coverage -- [x] Support multiple HTTPS requests per MITM tunnel when clients keep the tunnel open. -- [ ] Add HTTP/2 support or an explicit downgrade strategy. -- [ ] Improve handling for large or streaming bodies with safe memory limits. +- [ ] Add HTTP/2 support or an explicit note on mitmproxy's own HTTP/2 handling. +- [x] Improve handling for large or streaming bodies with safe memory limits. SSE (`text/event-stream`) bodies are upserted by stable flow id, persisted periodically during reception, and capped to the most recent 256 KiB with a truncation indicator. Non-streaming bodies are capped to 256 KiB before bridge serialization so bursty browser sessions do not flood the Rust/UI pipeline with large base64 payloads. - [ ] Add explicit binary body handling and content previews by MIME type. ### P6 - Analysis and Exports @@ -188,23 +195,43 @@ Roadmap state legend: `[ ]` not started, `[~]` in progress, `[x]` completed. - [ ] Add HAR import to inspect previously captured sessions. - [ ] Add request snippet export, starting with `curl`, Java `java.net.http`, OkHttp, Python `requests`, and JavaScript `fetch`. - [ ] Add inline explanations for common HTTP methods, status codes, and standard headers. -- [ ] Add performance details: phase timings where available, compressed and decoded body sizes, content encoding, and cache header summary. +- [~] Add performance details: phase timings where available, compressed and decoded body sizes, content encoding, and cache header summary. Phase timings done: `bridge_addon.py` now derives send/wait(TTFB)/receive durations from mitmproxy's own per-flow timestamps, persisted alongside `duration_ms` (new `send_duration_ms`/`wait_duration_ms`/`receive_duration_ms` columns) and shown as a small stacked bar + legend in the Overview tab. Still open: body size/encoding/cache-header summary. + +### P7 - Packaging + +- [ ] Investigate bundling mitmproxy as a Tauri sidecar so it is not a separate install step for end users (today: mitmproxy is a documented prerequisite, the same pattern as the JDK prerequisite before it). Researched: mitmproxy ships self-contained single-file binaries for Windows and Linux (a clean fit for Tauri's sidecar mechanism), but macOS ships as a multi-file app bundle that does not map onto a single sidecar binary. Not worth the added release-artifact size and CI complexity for v1; keep mitmproxy as a documented prerequisite for now. +- [~] Verify the same capture flow on Linux (mitmproxy's `local:` mode also supports Linux via a native, non-eBPF path for this use case). Verified at the `mitmdump`/trust-script level in WSL2 Ubuntu 22.04: targeted-process isolation works identically to Windows, and two real bugs found in that pass are fixed — `crates/mitm_engine` now detects and reports the non-root case instead of hanging (`mitmdump --mode local:` on Linux requires root), and `mitm_engine::trust::install_os_trust`'s Linux path now installs the CA via `update-ca-certificates` when running as root, with a corrected (but still Debian/Ubuntu-unsupported) p11-kit fallback otherwise. Still open: nobody has run the actual ProcTap Tauri app end-to-end on a real Linux desktop (needs a full Linux Tauri toolchain — webkit2gtk etc. — not available in the sessions so far). + +### P8 - Fault Injection + +Scoped, rule-based fault injection for application testing, in the spirit of Toxiproxy but HTTP-aware (matches on host/method/path/headers/body, not just raw TCP). This is the one exception carved out of the read-only MVP constraint below; see `AGENTS.md`. + +- [x] Define the fault rule model (match + action) in a new `crates/fault_rules` crate and persist it in `crates/storage` (`fault_rules` table, upsert/delete/list). +- [x] Extend the `mitm_engine` <-> `bridge_addon.py` bridge to a duplex control channel (`MitmEngine::run_with_fault_rules`) so rules can be pushed to the addon at capture start and updated live via a `tokio::sync::watch` channel. +- [x] Implement request/response actions in `bridge_addon.py`: delay, forced status/body/headers, abort (kill), throttle (approximated as a single proportional delay rather than true chunked streaming; revisit if that's not realistic enough), and streamed-response disconnect for SSE/EventSource reconnection tests. +- [x] Tauri commands (`list_fault_rules`, `upsert_fault_rule`, `delete_fault_rule`) broadcasting the enabled rule set to every running capture session. +- [x] Desktop UI: a toggleable "Fault rules" panel (name, target request/response, host/method/path match, action editor for delay/force status/abort/throttle, enable toggle, delete). Verified via `tsc --noEmit`, `vite build`, and the dev server transforming the new component without errors; not click-tested inside an actual Tauri window in this session (no browser automation tool or Tauri runtime available here) — worth a manual pass before relying on it. +- [x] Add probabilistic rule application (`0%`-`100%`, default `100%`) so a matching fault can affect only a random subset of requests/streams. ### Explicitly Out Of Scope Until Read-Only MVP Is Stable -- [ ] Request/response rewrite and breakpoint editing. -- [ ] Mock endpoints, synthetic responses, failure injection, and replay/resend. +- [ ] Request/response rewrite and breakpoint editing (beyond the scoped fault-injection rules in P8). +- [ ] Mock endpoints, synthetic responses, and replay/resend. - [ ] Mobile, browser, Docker, Electron, WebRTC, IPFS, Ethereum, GraphQL, and gRPC integrations. ## Known Limitations -- The MVP remains read-only and intentionally does not support rewrite, replay, mocking, or mutation. -- OpenIntercept is not functional yet; documented workflows describe the intended MVP behavior and may not work end-to-end today. -- HTTPS setup still depends on platform and client constraints; some flows still require manual trust configuration. -- Java attach currently injects proxy properties but does not yet install CA trust dynamically into the target JVM. -- Some Java clients may ignore JVM proxy properties, reuse prebuilt connection pools, use custom SSL contexts, or pin certificates. -- Process attribution is not implemented; captures are not reliably linked to a Java PID yet. -- HTTP/2 is not supported yet; the proxy currently focuses on HTTP/1.1 flows. +- The MVP remains read-only by default and intentionally does not support rewrite, replay, or mocking. Fault injection (P8) is the one carved-out exception, currently engine-only with no UI. +- ProcTap is not functional yet; documented workflows describe the intended MVP behavior and may not work end-to-end today. +- mitmproxy is a required external dependency; ProcTap does not bundle it yet. +- Trust automation imports the CA into trust stores it can reach without elevation on Windows and macOS; some clients (custom `SSLContext`, certificate pinning, or already-running processes) may still not decrypt correctly. +- **Linux is not fully supported yet.** `mitmdump --mode local:` requires root on Linux, unlike Windows and macOS; ProcTap detects this and reports a clear error instead of hanging, but does not (yet) offer an in-app elevation flow, so capture only works today if ProcTap itself is run as root. OS trust install on Linux also requires root (`update-ca-certificates` on Debian/Ubuntu); there is currently no confirmed no-root trust-install path on that distro family, and Fedora/RHEL-style distros are unverified. Verified so far at the `mitmdump`/trust-script level only — the full Tauri app has not yet been run end-to-end on a real Linux desktop. +- Process attribution is PID-based only when a specific instance is picked from the picker's disambiguation list (shown when multiple running processes share a name); capturing by bare name (the common case, more resilient to that process restarting mid-session) leaves `processPid` unset on captured exchanges, since mitmproxy itself never exposes a flow's originating PID. +- The explicit-proxy mode has no process attribution at all, by design — any client pointed at it is captured, tagged `External client`, regardless of what process it is. It runs a second, independent `mitmdump` subprocess sharing the same confdir/CA as the picker session; the two are unrelated otherwise (starting/stopping one never affects the other). +- When several picker captures are active, ProcTap uses one combined `local:,` mitmproxy session. Mitmproxy still does not expose the originating PID per captured flow in addon APIs, so combined local captures may be labeled with a shared process name instead of an exact PID. Exact PID attribution remains reliable when only one PID-scoped picker capture is active. +- HTTP/2 is not supported by ProcTap's own handling yet. +- Live body streaming is currently implemented for SSE responses (`text/event-stream`) only. Other long-lived chunked responses still follow the regular completed-flow path until expanded deliberately. +- Live traffic search does not scan every captured body while polling; the list uses body-free summaries to stay responsive and loads full bodies only for selected captures and exports. - Capture persistence is bounded and intended for local debugging, not long-term archival. ## Stack @@ -213,6 +240,7 @@ Roadmap state legend: `[ ]` not started, `[~]` in progress, `[x]` completed. - Tauri - React - SQLite +- mitmproxy (external engine subprocess) ## Prerequisites @@ -234,29 +262,27 @@ sudo apt update && sudo apt install -y \ openjdk-21-jdk ``` -Then install Rust via [rustup](https://rustup.rs) and Node.js via [nvm](https://github.com/nvm-sh/nvm) or the [official installer](https://nodejs.org). +Then install Rust via [rustup](https://rustup.rs), Node.js via [nvm](https://github.com/nvm-sh/nvm) or the [official installer](https://nodejs.org), and [mitmproxy](https://mitmproxy.org/) (e.g. `pip install mitmproxy` or your distro's package). ### Windows -Install [Node.js](https://nodejs.org), a JDK (e.g. [Eclipse Temurin](https://adoptium.net)), and the [Tauri prerequisites](https://tauri.app/start/prerequisites/). +Install [Node.js](https://nodejs.org), [mitmproxy](https://mitmproxy.org/), a JDK (e.g. [Eclipse Temurin](https://adoptium.net)), and the [Tauri prerequisites](https://tauri.app/start/prerequisites/). ## Repository Layout ```txt apps/desktop/ Tauri + React desktop application -crates/capture/ Shared capture models -crates/certs/ Local CA and certificate generation -crates/java_processes/ Java process discovery -crates/proxy/ Local proxy entry point +crates/capture/ Capture model + in-memory capture store/sink +crates/mitm_engine/ Spawns and drives mitmdump as the capture engine, CA trust automation +crates/processes/ OS process discovery, tags likely JVM processes crates/storage/ SQLite storage boundary docs/ Product and technical notes -java/ Java agent and attach helper -scripts/ Local helper scripts for Java/truststore workflows +scripts/ Local helper scripts for JDK truststore management ``` ## Development -Install Node.js, Rust, and Tauri prerequisites, then run: +Install Node.js, Rust, Tauri prerequisites, and mitmproxy, then run: ```bash npm install @@ -278,18 +304,14 @@ If you only need the frontend during UI work: npm run web:dev ``` -The Java helper artifacts can be rebuilt with: - -```powershell -./scripts/build-java.ps1 -``` - -or: +To manually verify ProcTap phase timings (`send`, `wait`/TTFB, `receive`) against controlled HTTP behavior: ```bash -./scripts/build-java.sh +npm run timing-server ``` +Then capture `node.exe` or use the explicit proxy and call endpoints such as `/ttfb?wait=1500`, `/receive?chunks=6&interval=250`, `/combo?wait=800&chunks=5&interval=300`, or POST a throttled large body to `/upload` to stretch `send`. + ## CI/CD GitHub Actions runs validation on pull requests and pushes to `main`: @@ -301,8 +323,8 @@ GitHub Actions runs validation on pull requests and pushes to `main`: The same workflow also publishes downloadable WIP build artifacts for: -- Windows x64: `openintercept-windows-x64`; -- Ubuntu x64: `openintercept-ubuntu-x64`. +- Windows x64: `proctap-windows-x64`; +- Ubuntu x64: `proctap-ubuntu-x64`. These artifacts are unsigned development builds. They are intended to make testing easier while the project is not functional yet, not to provide stable releases or production-ready installers. @@ -316,7 +338,7 @@ When Semantic Release creates a version, the CI builds Windows and Ubuntu deskto ## Contributing -Contributions are welcome, especially around the core read-only proxy workflow, HTTPS setup ergonomics, UI clarity, and debugging experience. +Contributions are welcome, especially around the core capture workflow, HTTPS trust ergonomics, UI clarity, and debugging experience. Start here: @@ -327,19 +349,17 @@ Start here: Good first contribution areas: -- HTTPS setup guidance and failure diagnosis; +- HTTPS trust guidance and failure diagnosis; - capture inspection usability; -- JVM trust and attach reliability; +- JVM trust automation reliability; - tests, docs, and reproducible bug reports. -## Spring Boot HTTPS Debugging - -To route a Spring Boot app through OpenIntercept without Java attach, create a Java truststore from the OpenIntercept CA and start the app with JVM proxy options. This explicit proxy startup flow is currently the most reliable JVM path. +## Spring Boot / JVM Capture -See `docs/spring-boot-proxy.md`. +ProcTap captures JVM traffic the same way as any other process: pick the JVM's process name in the capture picker, no proxy configuration needed on the app itself. ProcTap automatically installs mitmproxy's CA into every discovered JDK truststore when the target looks like a JVM process. -For a dedicated local dev JDK, `scripts/manage-jdk-truststore.sh` (Linux/macOS) or `scripts/manage-jdk-truststore.ps1` (Windows) can import or remove the OpenIntercept CA from that JDK's `cacerts` truststore interactively. +See `docs/spring-boot-proxy.md` for details, client-specific notes, and how to manage the JDK truststore manually with `scripts/manage-jdk-truststore.ps1` (Windows) or `scripts/manage-jdk-truststore.sh` (Linux/macOS). ## License -OpenIntercept is licensed under the [MIT License](LICENSE). +ProcTap is licensed under the [MIT License](LICENSE). diff --git a/SECURITY.md b/SECURITY.md index 67bb455..309c75d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Reporting a Vulnerability -If you believe you found a security vulnerability in OpenIntercept, please do not open a public GitHub issue first. +If you believe you found a security vulnerability in ProcTap, please do not open a public GitHub issue first. Instead, report it privately through one of these paths: diff --git a/apps/desktop/index.html b/apps/desktop/index.html index daec9d9..b051590 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -3,7 +3,7 @@ - OpenIntercept + ProcTap
diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e4c6d66..89a71c3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,12 +1,11 @@ { - "name": "@openintercept/desktop", + "name": "@proctap/desktop", "version": "0.1.0", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", - "build:java": "node ../../scripts/build-java.mjs", "typecheck": "tsc --noEmit", "tauri:dev": "tauri dev", "tauri:build": "tauri build" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 54cbcb4..ffc466f 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "openintercept-desktop" +name = "proctap-desktop" version = "0.1.0" edition.workspace = true license.workspace = true @@ -8,11 +8,14 @@ license.workspace = true tauri-build = { version = "2.0", features = [] } [dependencies] +anyhow.workspace = true capture = { path = "../../../crates/capture" } -certs = { path = "../../../crates/certs" } -java_processes = { path = "../../../crates/java_processes" } -proxy = { path = "../../../crates/proxy" } +dirs = "6.0" +fault_rules = { path = "../../../crates/fault_rules" } +mitm_engine = { path = "../../../crates/mitm_engine" } +processes = { path = "../../../crates/processes" } serde.workspace = true storage = { path = "../../../crates/storage" } tauri = { version = "2.1", features = [] } tokio.workspace = true +uuid.workspace = true diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index f567f93..261851f 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -1,17 +1,3 @@ fn main() { tauri_build::build(); - println!("cargo::rustc-check-cfg=cfg(java_jars_bundled)"); - - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); - let agent_jar = - std::path::Path::new(&manifest_dir).join("../../../java/build/openintercept-agent.jar"); - let helper_jar = std::path::Path::new(&manifest_dir) - .join("../../../java/build/openintercept-attach-helper.jar"); - - if agent_jar.exists() && helper_jar.exists() { - println!("cargo:rustc-cfg=java_jars_bundled"); - } - - println!("cargo:rerun-if-changed=../../../java/build/openintercept-agent.jar"); - println!("cargo:rerun-if-changed=../../../java/build/openintercept-attach-helper.jar"); } diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index c0fb1af..1453887 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,107 +1,878 @@ -#[cfg(java_jars_bundled)] -const AGENT_JAR: &[u8] = include_bytes!("../../../../java/build/openintercept-agent.jar"); -#[cfg(java_jars_bundled)] -const HELPER_JAR: &[u8] = include_bytes!("../../../../java/build/openintercept-attach-helper.jar"); - -use capture::CapturedExchange; -use certs::{CaStatus, HostCertificateStatus}; -use java_processes::{AttachResult, JavaProcess}; -use proxy::ProxyStatus; +use capture::{CapturedExchange, IgnoreRule}; +use fault_rules::FaultRule; +use mitm_engine::trust::CaStatus; +use mitm_engine::EngineLogSink; +use serde::Serialize; +use std::collections::{HashMap, VecDeque}; use std::path::PathBuf; -use std::sync::Arc; +use std::process::Command; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; use storage::SqliteStore; -use tauri::Manager; +use tauri::{Emitter, Manager}; +use tokio::sync::watch; +use uuid::Uuid; + +/// Broadcasts the current *enabled* fault rule set to every running +/// `mitm_engine` session (picker-based and explicit-proxy alike). Rules +/// aren't scoped to one session in the model (they match on host/method/path, +/// not on which capture started them), so every session subscribes to the +/// same channel and applies whichever rules match its own traffic. +type FaultRuleBroadcast = watch::Sender>; + +const MAX_APP_LOGS: usize = 1_000; + +type AppLogState = Arc; + +struct AppLogStore { + entries: Mutex>, + next_id: Mutex, + app_handle: Mutex>, +} + +impl Default for AppLogStore { + fn default() -> Self { + Self { + entries: Mutex::new(VecDeque::with_capacity(MAX_APP_LOGS)), + next_id: Mutex::new(1), + app_handle: Mutex::new(None), + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct AppLogEntry { + id: u64, + timestamp_ms: u64, + level: String, + source: String, + message: String, +} + +fn push_app_log( + logs: &AppLogState, + level: impl Into, + source: impl Into, + message: impl Into, +) { + let mut next_id = logs.next_id.lock().expect("app log id lock poisoned"); + let entry = AppLogEntry { + id: *next_id, + timestamp_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default(), + level: level.into(), + source: source.into(), + message: message.into(), + }; + *next_id += 1; + drop(next_id); + + { + let mut entries = logs.entries.lock().expect("app log lock poisoned"); + if entries.len() >= MAX_APP_LOGS { + entries.pop_front(); + } + entries.push_back(entry.clone()); + } + + if let Some(app_handle) = logs + .app_handle + .lock() + .expect("app log handle lock poisoned") + .as_ref() + { + let _ = app_handle.emit("app-log", entry); + } +} + +fn mitm_log_sink(logs: AppLogState, source: &'static str) -> EngineLogSink { + Arc::new(move |level, message| push_app_log(&logs, level, source, message)) +} + +/// Identifies one picker-based capture session: a target name plus an optional +/// disambiguating PID (see `mitm_engine::CaptureTarget`). Bare-name sessions +/// (`pid: None`) key on the name alone, so starting the same bare name twice +/// is a duplicate rather than two redundant `mitmdump` processes. +type SessionKey = (String, Option); + +/// One picker-based capture target as shown in the UI. These targets are +/// grouped into one local mitmdump engine (`local:a,b,c`) to avoid Windows' +/// multiple-local-redirector conflict. +struct CaptureHandle { + target: String, + display_name: String, + pid: Option, +} + +type CaptureSessionState = Arc>>; + +struct LocalEngineHandle { + spec: String, + task: tokio::task::JoinHandle<()>, +} + +type LocalEngineState = Arc>>; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct CaptureSession { + target: String, + display_name: String, + pid: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct StartCaptureResult { + session: CaptureSession, + trust_notice: Option, +} + +/// A running `mitm_engine` explicit-proxy session (`--mode regular@`), +/// entirely independent of the picker-based [`CaptureHandle`] above — the two +/// can run concurrently, each with its own `mitmdump` subprocess pointed at +/// the same confdir (so they share the mitmproxy CA), feeding the same +/// `capture::push_capture` sink. +struct ExplicitProxyHandle { + port: u16, + task: tokio::task::JoinHandle<()>, +} + +type ExplicitProxySessionState = Arc>>; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ExplicitProxyStatus { + running: bool, + port: Option, + trust_notice: Option, +} #[tauri::command] -fn list_java_processes() -> Result, String> { - java_processes::list_java_processes().map_err(|error| error.to_string()) +fn list_processes() -> Result, String> { + processes::list_processes().map_err(|error| error.to_string()) } #[tauri::command] -fn proxy_status() -> ProxyStatus { - proxy::status() +fn list_app_logs(app_logs: tauri::State<'_, AppLogState>) -> Vec { + app_logs + .entries + .lock() + .expect("app log lock poisoned") + .iter() + .cloned() + .collect() } #[tauri::command] -fn list_captured_exchanges() -> Vec { - proxy::captured_exchanges() +fn clear_app_logs(app_logs: tauri::State<'_, AppLogState>) { + app_logs + .entries + .lock() + .expect("app log lock poisoned") + .clear(); } #[tauri::command] -fn clear_captured_exchanges(store: tauri::State>) { - proxy::clear_captures(); - if let Err(e) = store.clear() { - eprintln!("failed to clear SQLite captures: {e}"); +fn capture_status(session: tauri::State<'_, CaptureSessionState>) -> Vec { + session + .lock() + .expect("capture session lock poisoned") + .values() + .map(|handle| CaptureSession { + target: handle.target.clone(), + display_name: handle.display_name.clone(), + pid: handle.pid, + }) + .collect() +} + +fn capture_sessions_from_map(sessions: &HashMap) -> Vec { + sessions + .values() + .map(|handle| CaptureSession { + target: handle.target.clone(), + display_name: handle.display_name.clone(), + pid: handle.pid, + }) + .collect() +} + +fn local_capture_mode(handles: &[CaptureSession]) -> Option { + match handles { + [] => None, + [single] => Some(mitm_engine::ProxyMode::Local(mitm_engine::CaptureTarget { + target_name: single.target.clone(), + display_name: single.display_name.clone(), + pid: single.pid, + })), + many => { + let spec = many + .iter() + .map(|session| { + session + .pid + .map(|pid| pid.to_string()) + .unwrap_or_else(|| session.target.clone()) + }) + .collect::>() + .join(","); + Some(mitm_engine::ProxyMode::LocalGroup { + spec, + display_name: "Combined local capture".to_string(), + }) + } } } +async fn restart_local_engine( + sessions: Vec, + local_engine: &LocalEngineState, + fault_rules: &FaultRuleBroadcast, + app_logs: &AppLogState, +) -> Result<(), String> { + if let Some(handle) = local_engine + .lock() + .expect("local engine lock poisoned") + .take() + { + push_app_log( + app_logs, + "info", + "capture", + format!("Restarting local capture engine; previous spec was local:{}.", handle.spec), + ); + handle.task.abort(); + } + + let Some(mode) = local_capture_mode(&sessions) else { + return Ok(()); + }; + + let spec = match &mode { + mitm_engine::ProxyMode::Local(target) => target + .pid + .map(|pid| pid.to_string()) + .unwrap_or_else(|| target.target_name.clone()), + mitm_engine::ProxyMode::LocalGroup { spec, .. } => spec.clone(), + mitm_engine::ProxyMode::Regular { .. } => unreachable!("local restart never uses regular mode"), + }; + + let mitmdump_path = mitm_engine::find_mitmdump().map_err(|error| error.to_string())?; + let data_dir = local_app_dir(); + let confdir = data_dir.join("mitm-ca"); + std::fs::create_dir_all(&confdir).map_err(|error| error.to_string())?; + let addon_path = data_dir.join("bridge_addon_local.py"); + + push_app_log( + app_logs, + "info", + "capture", + format!("Starting local capture engine with local:{spec}."), + ); + + let log_state = app_logs.clone(); + let engine = mitm_engine::MitmEngine::spawn_with_log_sink( + &mitmdump_path, + &addon_path, + &confdir, + &mode, + Some(mitm_log_sink(log_state.clone(), "mitmdump")), + ) + .await + .map_err(|error| error.to_string())?; + + let rules_rx = fault_rules.subscribe(); + let task_spec = spec.clone(); + let task = tokio::spawn(async move { + if let Err(error) = engine + .run_with_fault_rules(&mode, capture::push_capture, rules_rx) + .await + { + let message = format!("mitm_engine local session local:{task_spec} ended: {error}"); + eprintln!("{message}"); + push_app_log(&log_state, "error", "capture", message); + } + }); + + *local_engine.lock().expect("local engine lock poisoned") = Some(LocalEngineHandle { spec, task }); + Ok(()) +} + +/// Adds a new picker-based capture session alongside any already running — +/// unlike the old single-slot design, this never stops an existing session. +/// Starting the same `(target, pid)` pair twice is rejected rather than +/// leaking a second `mitmdump` process for a target already being captured. #[tauri::command] -fn ca_status() -> Result { - certs::ca_status().map_err(|error| error.to_string()) +async fn start_capture( + target: String, + pid: Option, + display_name: Option, + session: tauri::State<'_, CaptureSessionState>, + local_engine: tauri::State<'_, LocalEngineState>, + fault_rules: tauri::State<'_, FaultRuleBroadcast>, + app_logs: tauri::State<'_, AppLogState>, +) -> Result { + let target = target.trim().to_string(); + if target.is_empty() { + return Err("process name must not be empty".to_string()); + } + let display_name = display_name + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| target.clone()); + + let key: SessionKey = (target.to_ascii_lowercase(), pid); + if session + .lock() + .expect("capture session lock poisoned") + .contains_key(&key) + { + return Err(match pid { + Some(pid) => format!("Already capturing {target} (PID {pid})."), + None => format!("Already capturing {target}."), + }); + } + + let data_dir = local_app_dir(); + let confdir = data_dir.join("mitm-ca"); + std::fs::create_dir_all(&confdir).map_err(|error| error.to_string())?; + + let log_state = app_logs.inner().clone(); + push_app_log( + &log_state, + "info", + "capture", + match pid { + Some(pid) => format!("Starting capture for {target} (PID {pid})."), + None => format!("Starting capture for {target}."), + }, + ); + + let (previous_sessions, sessions) = { + let mut sessions = session.lock().expect("capture session lock poisoned"); + let previous_sessions = capture_sessions_from_map(&sessions); + sessions.insert( + key.clone(), + CaptureHandle { + target: target.clone(), + display_name: display_name.clone(), + pid, + }, + ); + (previous_sessions, capture_sessions_from_map(&sessions)) + }; + + if let Err(error) = restart_local_engine( + sessions, + local_engine.inner(), + fault_rules.inner(), + &log_state, + ) + .await + { + session + .lock() + .expect("capture session lock poisoned") + .remove(&key); + let _ = restart_local_engine( + previous_sessions, + local_engine.inner(), + fault_rules.inner(), + &log_state, + ) + .await; + return Err(error); + } + + let is_java_target = processes::is_java_process_name(&target); + let trust_notice = ensure_ca_trust(confdir, is_java_target).await; + + Ok(StartCaptureResult { + session: CaptureSession { + target, + display_name, + pid, + }, + trust_notice, + }) +} + +/// Waits for mitmdump to write its CA into `confdir` (it does this on startup, +/// before accepting the bridge connection), then installs it into the OS trust +/// store and, for JVM targets, into every discovered JDK truststore. Best +/// effort: failures are reported back as a notice, never fail capture start. +async fn ensure_ca_trust(confdir: PathBuf, is_java_target: bool) -> Option { + let cert_path = mitm_engine::trust::ca_cert_path(&confdir); + + for _ in 0..30 { + if cert_path.exists() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + if !cert_path.exists() { + return Some( + "mitmdump did not generate a CA certificate in time; trust setup skipped.".to_string(), + ); + } + + let mut messages = Vec::new(); + + let os_result = { + let cert_path = cert_path.clone(); + tokio::task::spawn_blocking(move || mitm_engine::trust::install_os_trust(&cert_path)) + .await + .unwrap_or_else(|error| Err(anyhow::anyhow!(error.to_string()))) + }; + messages.push(match os_result { + Ok(message) => message, + Err(error) => format!("OS trust store: {error}"), + }); + + if is_java_target { + let root = workspace_root(); + let jdk_result = { + let cert_path = cert_path.clone(); + tokio::task::spawn_blocking(move || { + mitm_engine::trust::install_jdk_trust(&root, &cert_path) + }) + .await + .unwrap_or_else(|error| Err(anyhow::anyhow!(error.to_string()))) + }; + messages.push(match jdk_result { + Ok(message) => message, + Err(error) => format!("JDK truststore: {error}"), + }); + } + + Some(messages.join("\n")) } +/// Stops one specific session by `(target, pid)` and returns the sessions +/// still running afterward. Stopping a target that isn't running is a no-op, +/// not an error — the UI never has a stale "stop" button for a dead session. #[tauri::command] -fn ensure_ca() -> Result { - certs::ensure_ca().map_err(|error| error.to_string()) +async fn stop_capture( + target: String, + pid: Option, + session: tauri::State<'_, CaptureSessionState>, + local_engine: tauri::State<'_, LocalEngineState>, + fault_rules: tauri::State<'_, FaultRuleBroadcast>, + app_logs: tauri::State<'_, AppLogState>, +) -> Result, String> { + let key: SessionKey = (target.trim().to_ascii_lowercase(), pid); + let (removed, remaining) = { + let mut sessions = session.lock().expect("capture session lock poisoned"); + let removed = sessions.remove(&key); + let remaining = capture_sessions_from_map(&sessions); + (removed, remaining) + }; + + if let Some(handle) = removed { + push_app_log( + app_logs.inner(), + "info", + "capture", + match handle.pid { + Some(pid) => format!("Stopping capture for {} (PID {pid}).", handle.display_name), + None => format!("Stopping capture for {}.", handle.display_name), + }, + ); + + if let Err(error) = restart_local_engine( + remaining.clone(), + local_engine.inner(), + fault_rules.inner(), + app_logs.inner(), + ) + .await + { + push_app_log( + app_logs.inner(), + "error", + "capture", + format!("failed to restart local capture after stop: {error}"), + ); + } + } + + Ok(remaining) } #[tauri::command] -fn ensure_host_certificate(hostname: String) -> Result { - certs::ensure_host_certificate(&hostname).map_err(|error| error.to_string()) +fn explicit_proxy_status( + session: tauri::State<'_, ExplicitProxySessionState>, +) -> ExplicitProxyStatus { + match &*session + .lock() + .expect("explicit proxy session lock poisoned") + { + Some(handle) => ExplicitProxyStatus { + running: true, + port: Some(handle.port), + trust_notice: None, + }, + None => ExplicitProxyStatus { + running: false, + port: None, + trust_notice: None, + }, + } } +/// Starts a standing explicit proxy (`--mode regular@`) as a second, +/// independent `mitmdump` subprocess alongside whatever the picker-based +/// [`CaptureSessionState`] is doing. Both point at the same confdir, so they +/// share the mitmproxy CA (whichever starts first generates it; the second +/// just reuses the files already on disk) and feed the same `capture` +/// sink/store — verified live that two `mitmdump` processes pointed at the +/// same confdir concurrently don't corrupt anything, including when both are +/// started back-to-back before the first has finished writing its CA. #[tauri::command] -fn attach_java_process(app: tauri::AppHandle, pid: u32) -> Result { - let jar_dir = resolve_jar_dir(&app); - let helper_jar = jar_dir.join("openintercept-attach-helper.jar"); - let agent_jar = jar_dir.join("openintercept-agent.jar"); - let ca_cert_path = certs::ca_status() - .ok() - .filter(|s| s.available) - .map(|s| PathBuf::from(s.cert_path)); +async fn start_explicit_proxy( + port: u16, + session: tauri::State<'_, ExplicitProxySessionState>, + fault_rules: tauri::State<'_, FaultRuleBroadcast>, + app_logs: tauri::State<'_, AppLogState>, +) -> Result { + if port == 0 { + return Err("port must not be 0".to_string()); + } + + stop_explicit_proxy_session(&session); + + let mitmdump_path = mitm_engine::find_mitmdump().map_err(|error| error.to_string())?; + let data_dir = local_app_dir(); + let confdir = data_dir.join("mitm-ca"); + std::fs::create_dir_all(&confdir).map_err(|error| error.to_string())?; + // Distinct addon-script path from the picker session: two `mitmdump` + // processes both trying to (re)write the same file on startup is an + // avoidable race even though the *content* is identical. + let addon_path = data_dir.join("bridge_addon_explicit.py"); - java_processes::attach_java_process( - pid, - helper_jar, - agent_jar, - "127.0.0.1", - 8877, - ca_cert_path.as_deref(), + let mode = mitm_engine::ProxyMode::Regular { port }; + + let log_state = app_logs.inner().clone(); + push_app_log( + &log_state, + "info", + "explicit-proxy", + format!("Starting explicit proxy on 127.0.0.1:{port}."), + ); + + let engine = mitm_engine::MitmEngine::spawn_with_log_sink( + &mitmdump_path, + &addon_path, + &confdir, + &mode, + Some(mitm_log_sink(log_state.clone(), "mitmdump")), ) - .map_err(|error| error.to_string()) -} + .await + .map_err(|error| error.to_string())?; -fn resolve_jar_dir(app: &tauri::AppHandle) -> PathBuf { - #[cfg(java_jars_bundled)] - { - let data_dir = app - .path() - .app_data_dir() - .unwrap_or_else(|_| PathBuf::from(".")); - let jar_dir = data_dir.join("jars"); - let _ = std::fs::create_dir_all(&jar_dir); - let agent_path = jar_dir.join("openintercept-agent.jar"); - let helper_path = jar_dir.join("openintercept-attach-helper.jar"); - if std::fs::metadata(&agent_path).map(|m| m.len()).unwrap_or(0) != AGENT_JAR.len() as u64 { - let _ = std::fs::write(&agent_path, AGENT_JAR); - } - if std::fs::metadata(&helper_path) - .map(|m| m.len()) - .unwrap_or(0) - != HELPER_JAR.len() as u64 + let rules_rx = fault_rules.subscribe(); + let task = tokio::spawn(async move { + if let Err(error) = engine + .run_with_fault_rules(&mode, capture::push_capture, rules_rx) + .await { - let _ = std::fs::write(&helper_path, HELPER_JAR); + let message = + format!("mitm_engine explicit proxy session on port {port} ended: {error}"); + eprintln!("{message}"); + push_app_log(&log_state, "error", "explicit-proxy", message); } - return jar_dir; + }); + + *session + .lock() + .expect("explicit proxy session lock poisoned") = Some(ExplicitProxyHandle { port, task }); + + // Any client pointed at this proxy could be a JVM app that can't be + // targeted by the process picker at all (e.g. containerized, remote) — + // that's the whole point of this mode — so always attempt JDK truststore + // import too, unlike the picker path where it's gated on the target + // looking like a JVM process by name. + let trust_notice = ensure_ca_trust(confdir, true).await; + + Ok(ExplicitProxyStatus { + running: true, + port: Some(port), + trust_notice, + }) +} + +#[tauri::command] +fn stop_explicit_proxy( + session: tauri::State<'_, ExplicitProxySessionState>, + app_logs: tauri::State<'_, AppLogState>, +) -> ExplicitProxyStatus { + if session + .lock() + .expect("explicit proxy session lock poisoned") + .is_some() + { + push_app_log( + app_logs.inner(), + "info", + "explicit-proxy", + "Stopping explicit proxy.", + ); + } + stop_explicit_proxy_session(&session); + ExplicitProxyStatus { + running: false, + port: None, + trust_notice: None, } - #[cfg(not(java_jars_bundled))] +} + +fn stop_explicit_proxy_session(session: &ExplicitProxySessionState) { + if let Some(handle) = session + .lock() + .expect("explicit proxy session lock poisoned") + .take() { - let _ = app; - workspace_root().join("java").join("build") + handle.task.abort(); + } +} + +#[tauri::command] +fn launch_proxied_browser(url: String, port: u16) -> Result<(), String> { + let url = url.trim(); + if !(url.starts_with("http://") || url.starts_with("https://")) { + return Err("URL must start with http:// or https://".to_string()); + } + if port == 0 { + return Err("proxy port must not be 0".to_string()); + } + + let browser = find_chromium_browser() + .ok_or_else(|| "Could not find Chrome, Edge, Chromium, or Brave.".to_string())?; + let profile_dir = local_app_dir().join("proxied-browser-profile"); + std::fs::create_dir_all(&profile_dir).map_err(|error| error.to_string())?; + + Command::new(&browser) + .arg(format!("--user-data-dir={}", profile_dir.display())) + .arg(format!("--proxy-server=http://127.0.0.1:{port}")) + .arg("--proxy-bypass-list=<-loopback>") + .arg("--new-window") + .arg(url) + .spawn() + .map_err(|error| format!("failed to launch {}: {error}", browser.display()))?; + + Ok(()) +} + +fn find_chromium_browser() -> Option { + browser_candidates().into_iter().find(|path| path.exists()) +} + +#[cfg(windows)] +fn browser_candidates() -> Vec { + let mut candidates = Vec::new(); + if let Some(program_files) = std::env::var_os("ProgramFiles") { + let root = PathBuf::from(program_files); + candidates.push(root.join("Google/Chrome/Application/chrome.exe")); + candidates.push(root.join("Microsoft/Edge/Application/msedge.exe")); + candidates.push(root.join("BraveSoftware/Brave-Browser/Application/brave.exe")); + } + if let Some(program_files_x86) = std::env::var_os("ProgramFiles(x86)") { + let root = PathBuf::from(program_files_x86); + candidates.push(root.join("Google/Chrome/Application/chrome.exe")); + candidates.push(root.join("Microsoft/Edge/Application/msedge.exe")); + } + if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") { + let root = PathBuf::from(local_app_data); + candidates.push(root.join("Google/Chrome/Application/chrome.exe")); + candidates.push(root.join("Microsoft/Edge/Application/msedge.exe")); + candidates.push(root.join("Chromium/Application/chrome.exe")); + } + candidates +} + +#[cfg(target_os = "macos")] +fn browser_candidates() -> Vec { + vec![ + PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), + PathBuf::from("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"), + PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"), + PathBuf::from("/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"), + ] +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn browser_candidates() -> Vec { + vec![ + PathBuf::from("/usr/bin/google-chrome"), + PathBuf::from("/usr/bin/google-chrome-stable"), + PathBuf::from("/usr/bin/microsoft-edge"), + PathBuf::from("/usr/bin/chromium"), + PathBuf::from("/usr/bin/chromium-browser"), + PathBuf::from("/usr/bin/brave-browser"), + ] +} + +#[tauri::command] +fn list_captured_exchanges() -> Vec { + capture::captured_exchanges() +} + +#[tauri::command] +fn list_captured_exchange_summaries() -> Vec { + capture::captured_exchange_summaries() +} + +#[tauri::command] +fn get_captured_exchange(id: Uuid) -> Option { + capture::captured_exchange(id) +} + +#[tauri::command] +fn clear_captured_exchanges(store: tauri::State>) { + capture::clear_captures(); + if let Err(e) = store.clear() { + eprintln!("failed to clear SQLite captures: {e}"); } } -#[cfg(not(java_jars_bundled))] +#[tauri::command] +fn set_captured_exchange_pinned( + id: Uuid, + pinned: bool, + store: tauri::State>, +) -> Result, String> { + store + .set_exchange_pinned(id, pinned) + .map_err(|error| error.to_string())?; + Ok(capture::set_capture_pinned(id, pinned)) +} + +#[tauri::command] +fn delete_captured_exchange(id: Uuid, store: tauri::State>) -> Result<(), String> { + store + .delete_exchange(id) + .map_err(|error| error.to_string())?; + capture::delete_capture(id); + Ok(()) +} + +#[tauri::command] +fn list_ignored_hosts(store: tauri::State>) -> Result, String> { + store + .list_ignored_hosts() + .map_err(|error| error.to_string()) +} + +/// Normalizes an optional method into `None` (any method) or an uppercase +/// verb, treating blank strings the same as absent. +fn normalize_method(method: Option) -> Option { + method + .map(|m| m.trim().to_ascii_uppercase()) + .filter(|m| !m.is_empty()) +} + +/// Adds a host+method rule to the ignore list so matching exchanges stop +/// being recorded from now on — already-captured exchanges are left +/// untouched. +#[tauri::command] +fn add_ignored_host( + host: String, + method: Option, + store: tauri::State>, +) -> Result, String> { + let host = host.trim().to_ascii_lowercase(); + if host.is_empty() { + return Err("host must not be empty".to_string()); + } + let method = normalize_method(method); + store + .add_ignored_host(&host, method.as_deref()) + .map_err(|error| error.to_string())?; + let all = store + .list_ignored_hosts() + .map_err(|error| error.to_string())?; + capture::set_ignored_hosts(all.clone()); + Ok(all) +} + +#[tauri::command] +fn remove_ignored_host( + host: String, + method: Option, + store: tauri::State>, +) -> Result, String> { + let host = host.trim().to_ascii_lowercase(); + let method = normalize_method(method); + store + .remove_ignored_host(&host, method.as_deref()) + .map_err(|error| error.to_string())?; + let all = store + .list_ignored_hosts() + .map_err(|error| error.to_string())?; + capture::set_ignored_hosts(all.clone()); + Ok(all) +} + +#[tauri::command] +fn ca_status() -> CaStatus { + mitm_engine::trust::ca_status(&local_app_dir().join("mitm-ca")) +} + +/// No UI wires these up yet (see README roadmap P8): they exist so the fault +/// injection engine can be exercised end-to-end via `tauri::invoke` from +/// devtools or an integration test while the UI is still unbuilt. +#[tauri::command] +fn list_fault_rules(store: tauri::State>) -> Result, String> { + store.list_fault_rules().map_err(|error| error.to_string()) +} + +/// Persists the rule, then re-broadcasts the full *enabled* rule set to every +/// running capture session so a toggle takes effect without restarting +/// capture. +#[tauri::command] +fn upsert_fault_rule( + rule: FaultRule, + store: tauri::State>, + fault_rules: tauri::State<'_, FaultRuleBroadcast>, +) -> Result, String> { + store + .upsert_fault_rule(&rule) + .map_err(|error| error.to_string())?; + broadcast_enabled_rules(&store, &fault_rules) +} + +#[tauri::command] +fn delete_fault_rule( + id: Uuid, + store: tauri::State>, + fault_rules: tauri::State<'_, FaultRuleBroadcast>, +) -> Result, String> { + store + .delete_fault_rule(id) + .map_err(|error| error.to_string())?; + broadcast_enabled_rules(&store, &fault_rules) +} + +/// Reloads the full rule list from storage, pushes the enabled subset to +/// every subscribed session via the watch channel, and returns the full list +/// (enabled and disabled) for the caller to display. +fn broadcast_enabled_rules( + store: &SqliteStore, + fault_rules: &FaultRuleBroadcast, +) -> Result, String> { + let all = store + .list_fault_rules() + .map_err(|error| error.to_string())?; + let enabled = all.iter().filter(|rule| rule.enabled).cloned().collect(); + let _ = fault_rules.send(enabled); + Ok(all) +} + fn workspace_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .ancestors() @@ -111,17 +882,20 @@ fn workspace_root() -> PathBuf { } fn local_app_dir() -> PathBuf { - certs::app_data_dir().unwrap_or_else(|_| PathBuf::from(".")) + dirs::data_local_dir() + .map(|dir| dir.join("ProcTap")) + .unwrap_or_else(|| PathBuf::from(".")) } fn main() { tauri::Builder::default() .setup(|app| { - if let Err(error) = certs::ensure_ca() { - eprintln!("failed to ensure local CA: {error}"); - } + let app_logs = Arc::new(AppLogStore::default()); + *app_logs + .app_handle + .lock() + .expect("app log handle lock poisoned") = Some(app.handle().clone()); - let _ = app; let data_dir = local_app_dir(); let db_path = data_dir.join("captures.db"); @@ -129,6 +903,12 @@ fn main() { Ok(s) => Arc::new(s), Err(e) => { eprintln!("failed to open capture store: {e}"); + push_app_log( + &app_logs, + "error", + "storage", + format!("failed to open capture store: {e}"), + ); Arc::new( SqliteStore::open(std::path::Path::new(":memory:")) .expect("in-memory SQLite failed"), @@ -137,37 +917,96 @@ fn main() { }; match store.list_exchanges() { - Ok(existing) => proxy::preload_exchanges(existing), - Err(e) => eprintln!("failed to load stored captures: {e}"), + Ok(existing) => capture::preload_exchanges(existing), + Err(e) => { + eprintln!("failed to load stored captures: {e}"); + push_app_log( + &app_logs, + "error", + "storage", + format!("failed to load stored captures: {e}"), + ); + } + } + + match store.list_ignored_hosts() { + Ok(hosts) => capture::set_ignored_hosts(hosts), + Err(e) => { + eprintln!("failed to load ignored hosts: {e}"); + push_app_log( + &app_logs, + "error", + "storage", + format!("failed to load ignored hosts: {e}"), + ); + } } let sink_store = Arc::clone(&store); - proxy::set_capture_sink(move |exchange| { + let sink_logs = Arc::clone(&app_logs); + capture::set_capture_sink(move |exchange| { if let Err(e) = sink_store.insert_exchange(exchange) { eprintln!("failed to persist capture: {e}"); + push_app_log( + &sink_logs, + "error", + "storage", + format!("failed to persist capture: {e}"), + ); } }); - app.manage(store); - - tauri::async_runtime::block_on(async { - if let Err(error) = proxy::start_default().await { - eprintln!("failed to start proxy: {error}"); + let initial_rules = match store.list_fault_rules() { + Ok(rules) => rules.into_iter().filter(|rule| rule.enabled).collect(), + Err(e) => { + eprintln!("failed to load stored fault rules: {e}"); + push_app_log( + &app_logs, + "error", + "storage", + format!("failed to load stored fault rules: {e}"), + ); + Vec::new() } - }); + }; + let (fault_rules_tx, _) = watch::channel::>(initial_rules); + + push_app_log(&app_logs, "info", "app", "ProcTap desktop started."); + + app.manage(store); + app.manage(app_logs); + app.manage(CaptureSessionState::default()); + app.manage(LocalEngineState::default()); + app.manage(ExplicitProxySessionState::default()); + app.manage(fault_rules_tx); Ok(()) }) .invoke_handler(tauri::generate_handler![ - list_java_processes, - proxy_status, + list_processes, + list_app_logs, + clear_app_logs, + capture_status, + start_capture, + stop_capture, + explicit_proxy_status, + start_explicit_proxy, + stop_explicit_proxy, + launch_proxied_browser, list_captured_exchanges, + list_captured_exchange_summaries, + get_captured_exchange, clear_captured_exchanges, + set_captured_exchange_pinned, + delete_captured_exchange, ca_status, - ensure_ca, - ensure_host_certificate, - attach_java_process + list_fault_rules, + upsert_fault_rule, + delete_fault_rule, + list_ignored_hosts, + add_ignored_host, + remove_ignored_host, ]) .run(tauri::generate_context!()) - .expect("failed to run OpenIntercept desktop app"); + .expect("failed to run ProcTap desktop app"); } diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 56ff2de..fe8ac77 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,18 +1,18 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "OpenIntercept", + "productName": "ProcTap", "version": "0.1.0", - "identifier": "dev.openintercept.app", + "identifier": "dev.proctap.app", "build": { "beforeDevCommand": "npm run dev", - "beforeBuildCommand": "npm run build:java && npm run build", + "beforeBuildCommand": "npm run build", "devUrl": "http://localhost:1420", "frontendDist": "../dist" }, "app": { "windows": [ { - "title": "OpenIntercept", + "title": "ProcTap", "width": 1200, "height": 760, "minWidth": 900, diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 622eabf..e6de104 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,25 +1,56 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; -type JavaProcess = { +type ProcessInfo = { pid: number; name: string; command: string; + parentPid: number | null; + parentName: string | null; + ancestorChain: ProcessAncestor[]; + isJava: boolean; + javaArtifactName: string | null; + javaMainClass: string | null; + windowTitle: string | null; }; -type ProxyStatus = { +type ProcessAncestor = { + pid: number; + name: string; +}; + +type CaptureSession = { + target: string; + displayName: string; + pid: number | null; +}; + +type StartCaptureResult = { + session: CaptureSession; + trustNotice: string | null; +}; + +type ExplicitProxyStatus = { running: boolean; - bindAddress: string; + port: number | null; + trustNotice: string | null; }; type CaStatus = { available: boolean; - generated: boolean; certPath: string; - keyPath: string; fingerprintSha256: string; }; +type AppLogEntry = { + id: number; + timestampMs: number; + level: string; + source: string; + message: string; +}; + type HeaderPair = { name: string; value: string; @@ -27,6 +58,9 @@ type HeaderPair = { type CapturedExchange = { id: string; + pinned: boolean; + processPid?: number | null; + processName?: string | null; method: string; url: string; status?: number | null; @@ -35,36 +69,322 @@ type CapturedExchange = { requestBody?: number[] | null; responseBody?: number[] | null; durationMs?: number | null; + sendDurationMs?: number | null; + waitDurationMs?: number | null; + receiveDurationMs?: number | null; error?: string | null; + timestampMs: number; + streaming: boolean; + responseBodyTruncated: boolean; }; -type AttachResult = { - pid: number; - success: boolean; - stdout: string; - stderr: string; +type RuleTarget = 'request' | 'response'; + +type RuleMatch = { + host?: string | null; + method?: string | null; + pathPattern?: string | null; + headerEquals?: [string, string] | null; +}; + +type FaultAction = + | { kind: 'delay'; ms: number } + | { kind: 'force_status'; status: number; body?: string | null; headers?: [string, string][] | null } + | { kind: 'abort' } + | { kind: 'throttle'; bytesPerSecond: number } + | { kind: 'disconnect_stream'; afterMs: number; once: boolean }; + +type FaultRule = { + id: string; + name: string; + enabled: boolean; + probability: number; + target: RuleTarget; + match: RuleMatch; + action: FaultAction; }; -type AppView = 'inspection' | 'javaAttach'; +type RuleDraft = { + name: string; + target: RuleTarget; + host: string; + method: string; + pathPattern: string; + probabilityPercent: string; + actionKind: FaultAction['kind']; + delayMs: string; + status: string; + body: string; + bytesPerSecond: string; + disconnectAfterMs: string; + disconnectOnce: boolean; +}; + +function emptyRuleDraft(): RuleDraft { + return { + name: '', + target: 'response', + host: '', + method: '', + pathPattern: '', + probabilityPercent: '100', + actionKind: 'delay', + delayMs: '1000', + status: '500', + body: '', + bytesPerSecond: '1024', + disconnectAfterMs: '10000', + disconnectOnce: true, + }; +} + +function probabilityFromDraft(draft: RuleDraft): number { + const parsed = Number(draft.probabilityPercent); + if (!Number.isFinite(parsed)) return 1; + return Math.min(1, Math.max(0, parsed / 100)); +} + +function buildActionFromDraft(draft: RuleDraft): FaultAction { + switch (draft.actionKind) { + case 'force_status': + return { kind: 'force_status', status: Number(draft.status) || 500, body: draft.body.trim() || null, headers: null }; + case 'abort': + return { kind: 'abort' }; + case 'throttle': + return { kind: 'throttle', bytesPerSecond: Number(draft.bytesPerSecond) || 0 }; + case 'disconnect_stream': + return { kind: 'disconnect_stream', afterMs: Number(draft.disconnectAfterMs) || 0, once: draft.disconnectOnce }; + case 'delay': + default: + return { kind: 'delay', ms: Number(draft.delayMs) || 0 }; + } +} + +type IgnoreRule = { + host: string; + method: string | null; +}; + +const HIDDEN_HOSTS_STORAGE_KEY = 'proctap-hidden-hosts'; +const ANY_METHOD = 'ANY'; +const STATIC_RESOURCE_EXTENSIONS = new Set([ + 'avif', + 'bmp', + 'css', + 'eot', + 'gif', + 'ico', + 'jpeg', + 'jpg', + 'js', + 'map', + 'mjs', + 'png', + 'svg', + 'ttf', + 'wasm', + 'webmanifest', + 'webp', + 'woff', + 'woff2', +]); +const STATIC_RESOURCE_CONTENT_TYPES = [ + 'font/', + 'image/', + 'text/css', + 'text/javascript', + 'application/javascript', + 'application/x-javascript', + 'application/manifest+json', + 'application/wasm', +]; + +function loadHiddenHosts(): IgnoreRule[] { + try { + const raw = window.localStorage.getItem(HIDDEN_HOSTS_STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + // Migrates the pre-method format, which stored plain host strings. + return parsed.map((entry: unknown) => + typeof entry === 'string' ? { host: entry, method: null } : (entry as IgnoreRule), + ); + } catch { + return []; + } +} + +function sameHostRule(a: IgnoreRule, b: IgnoreRule): boolean { + return a.host.toLowerCase() === b.host.toLowerCase() && (a.method ?? null) === (b.method ?? null); +} + +function describeHostRule(rule: IgnoreRule): string { + return `${rule.method ?? ANY_METHOD} ${rule.host}`; +} + +function hostOf(exchange: CapturedExchange): string | null { + try { + return new URL(exchange.url).host; + } catch { + return null; + } +} + +function pathOf(exchange: CapturedExchange): string | null { + try { + return new URL(exchange.url).pathname; + } catch { + return null; + } +} + +function isStaticResourceExchange(exchange: CapturedExchange): boolean { + if (exchange.method !== 'GET' && exchange.method !== 'HEAD') return false; + + try { + const { pathname } = new URL(exchange.url); + const extension = pathname.split('/').pop()?.split('.').pop()?.toLowerCase(); + if (extension && STATIC_RESOURCE_EXTENSIONS.has(extension)) return true; + if (pathname.toLowerCase().startsWith('/assets/')) return true; + } catch { + return false; + } + + const contentType = headerValue(exchange.responseHeaders, 'content-type')?.toLowerCase() ?? ''; + return STATIC_RESOURCE_CONTENT_TYPES.some((type) => contentType.startsWith(type)); +} + +/// Matches `text` against `pattern`, where `*` matches any run of characters +/// (e.g. `*.google.com` matches `mail.google.com`). Mirrors the glob matcher +/// used server-side in `crates/capture`, so a pattern behaves identically +/// whether it only hides the view or also stops recording. +function globMatch(pattern: string, text: string): boolean { + const escaped = pattern + .split('*') + .map((segment) => segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('.*'); + return new RegExp(`^${escaped}$`, 'i').test(text); +} + +function describeMatch(match: RuleMatch): string { + const parts: string[] = []; + if (match.method) parts.push(match.method); + if (match.host) parts.push(match.host); + if (match.pathPattern) parts.push(`*${match.pathPattern}*`); + return parts.length > 0 ? parts.join(' ') : 'any request'; +} + +function describeAction(action: FaultAction): string { + switch (action.kind) { + case 'delay': + return `delay ${action.ms} ms`; + case 'force_status': + return `force ${action.status}`; + case 'abort': + return 'abort connection'; + case 'throttle': + return `throttle ${action.bytesPerSecond} B/s`; + case 'disconnect_stream': + return `disconnect stream after ${action.afterMs} ms${action.once ? ' once' : ''}`; + } +} + +function describeProbability(probability: number | null | undefined): string { + const value = probability ?? 1; + return `${Math.round(value * 100)}%`; +} + +function processDisplayName(process: ProcessInfo): string { + return process.javaArtifactName ?? process.javaMainClass ?? process.windowTitle ?? `PID ${process.pid}`; +} + +function processParentLabel(process: ProcessInfo): string | null { + if (process.ancestorChain.length === 0) return null; + return process.ancestorChain + .slice(0, 3) + .map((ancestor) => `${ancestor.name} ${ancestor.pid}`) + .join(' <- '); +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +function curlCommand(exchange: CapturedExchange): string { + const parts = ['curl', '-i', '-X', shellQuote(exchange.method), shellQuote(exchange.url)]; + for (const header of exchange.requestHeaders) { + parts.push('-H', shellQuote(`${header.name}: ${header.value}`)); + } + if (exchange.requestBody && exchange.requestBody.length > 0) { + parts.push('--data-raw', shellQuote(bytesToText(exchange.requestBody))); + } + return parts.join(' '); +} + +function httpieCommand(exchange: CapturedExchange): string { + const parts = ['http', '--ignore-stdin', shellQuote(exchange.method), shellQuote(exchange.url)]; + for (const header of exchange.requestHeaders) { + parts.push(shellQuote(`${header.name}:${header.value}`)); + } + + if (!exchange.requestBody || exchange.requestBody.length === 0) { + return parts.join(' '); + } + + return `printf %s ${shellQuote(bytesToText(exchange.requestBody))} | ${parts.filter((part) => part !== '--ignore-stdin').join(' ')}`; +} export function App() { - const [activeView, setActiveView] = useState('inspection'); - const [processes, setProcesses] = useState([]); - const [proxyStatus, setProxyStatus] = useState(null); + const [allProcesses, setAllProcesses] = useState([]); + const [captureSessions, setCaptureSessions] = useState([]); + const [trustNotice, setTrustNotice] = useState(null); + const [captureTargetInput, setCaptureTargetInput] = useState(''); + const [selectedPids, setSelectedPids] = useState([]); + const [isAddingCapture, setIsAddingCapture] = useState(false); + const [explicitProxyStatus, setExplicitProxyStatus] = useState(null); + const [explicitProxyTrustNotice, setExplicitProxyTrustNotice] = useState(null); + const [explicitProxyPortInput, setExplicitProxyPortInput] = useState('8877'); + const [isTogglingExplicitProxy, setIsTogglingExplicitProxy] = useState(false); + const [browserUrlInput, setBrowserUrlInput] = useState('http://localhost:4300'); + const [isLaunchingBrowser, setIsLaunchingBrowser] = useState(false); const [caStatus, setCaStatus] = useState(null); const [exchanges, setExchanges] = useState([]); const [selectedExchangeId, setSelectedExchangeId] = useState(null); - const [selectedPid, setSelectedPid] = useState(null); - const [attachResult, setAttachResult] = useState(null); - const [isAttaching, setIsAttaching] = useState(false); + const [selectedExchangeDetails, setSelectedExchangeDetails] = useState(null); + const [isLoadingSelectedExchange, setIsLoadingSelectedExchange] = useState(false); const [error, setError] = useState(null); const [search, setSearch] = useState(''); + const [hideStaticResources, setHideStaticResources] = useState(false); const [methodFilter, setMethodFilter] = useState(null); const [statusFilter, setStatusFilter] = useState(null); + const [splitPercent, setSplitPercent] = useState(35); + const trafficGridRef = useRef(null); + const exchangeListRef = useRef(null); + const splitPercentRef = useRef(35); + const [faultRules, setFaultRules] = useState([]); + const [appLogs, setAppLogs] = useState([]); + const [logsPanelOpen, setLogsPanelOpen] = useState(false); + const [logSearch, setLogSearch] = useState(''); + const [rulesPanelOpen, setRulesPanelOpen] = useState(false); + const [ruleFormOpen, setRuleFormOpen] = useState(false); + const [ruleDraft, setRuleDraft] = useState(emptyRuleDraft()); + const [isAddingRule, setIsAddingRule] = useState(false); + const [ignoredHosts, setIgnoredHosts] = useState([]); + const [hiddenHosts, setHiddenHosts] = useState(() => loadHiddenHosts()); + const [hostFiltersExpanded, setHostFiltersExpanded] = useState(false); + const [hostContextMenu, setHostContextMenu] = useState<{ + x: number; + y: number; + host: string; + path: string; + pattern: string; + method: string; + exchange: CapturedExchange; + } | null>(null); const methods = [...new Set(exchanges.map((e) => e.method))].sort(); function matchesFilters(exchange: CapturedExchange): boolean { + if (hideStaticResources && isStaticResourceExchange(exchange)) return false; if (methodFilter && exchange.method !== methodFilter) return false; if (statusFilter && !String(exchange.status ?? '').startsWith(statusFilter)) return false; if (search.trim()) { @@ -84,167 +404,743 @@ export function App() { return true; } - const filteredExchanges = [...exchanges].reverse().filter(matchesFilters); - const selectedExchange = exchanges.find((exchange) => exchange.id === selectedExchangeId) ?? exchanges[exchanges.length - 1]; + function hostRuleMatches(rule: IgnoreRule, host: string, exchange: CapturedExchange): boolean { + return ( + globMatch(rule.host, host) && + (rule.method === null || rule.method.toUpperCase() === exchange.method.toUpperCase()) + ); + } + + function matchesHostVisibility(exchange: CapturedExchange): boolean { + const host = hostOf(exchange); + if (host === null) return true; + // Ignoring a host also hides already-captured matches from view — it's + // implied by "ignored", so it isn't duplicated into the hidden-hosts list. + return ( + !hiddenHosts.some((rule) => hostRuleMatches(rule, host, exchange)) && + !ignoredHosts.some((rule) => hostRuleMatches(rule, host, exchange)) + ); + } + + const newestFirstExchanges = [...exchanges].reverse(); + const pinnedExchanges = newestFirstExchanges.filter((e) => e.pinned); + const filteredExchanges = [ + ...pinnedExchanges, + ...newestFirstExchanges.filter((e) => !e.pinned && matchesFilters(e) && matchesHostVisibility(e)), + ]; + const filteredAppLogs = appLogs.filter((entry) => { + const q = logSearch.trim().toLowerCase(); + if (!q) return true; + return [entry.level, entry.source, entry.message].join('\n').toLowerCase().includes(q); + }); + const selectedExchangeSummary = selectedExchangeId + ? filteredExchanges.find((exchange) => exchange.id === selectedExchangeId) ?? null + : null; + const selectedExchange = selectedExchangeDetails?.id === selectedExchangeId + ? selectedExchangeDetails + : selectedExchangeSummary; async function refresh() { setError(null); try { - const [nextProcesses, nextProxyStatus, nextCaStatus, nextExchanges] = await Promise.all([ - invoke('list_java_processes'), - invoke('proxy_status'), + const [nextCaptureSessions, nextExplicitProxyStatus, nextCaStatus, nextExchanges, nextFaultRules, nextIgnoredHosts] = await Promise.all([ + invoke('capture_status'), + invoke('explicit_proxy_status'), invoke('ca_status'), - invoke('list_captured_exchanges'), + invoke('list_captured_exchange_summaries'), + invoke('list_fault_rules'), + invoke('list_ignored_hosts'), ]); - setProcesses(nextProcesses); - setProxyStatus(nextProxyStatus); + setCaptureSessions(nextCaptureSessions); + setExplicitProxyStatus(nextExplicitProxyStatus); setCaStatus(nextCaStatus); setExchanges(nextExchanges); + setFaultRules(nextFaultRules); + setIgnoredHosts(nextIgnoredHosts); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } } + async function refreshAllProcesses() { + try { + setAllProcesses(await invoke('list_processes')); + } catch { + // background poll, don't surface as error + } + } + useEffect(() => { void refresh(); + const interval = window.setInterval(() => void refresh(), 1500); + return () => window.clearInterval(interval); + }, []); - const interval = window.setInterval(() => { - void refresh(); - }, 1500); + useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | null = null; + + invoke('list_app_logs') + .then((logs) => { + if (!cancelled) setAppLogs(logs); + }) + .catch(() => {}); + + listen('app-log', (event) => { + setAppLogs((prev) => [...prev.slice(-999), event.payload]); + }).then((dispose) => { + if (cancelled) dispose(); + else unlisten = dispose; + }).catch(() => {}); + + return () => { + cancelled = true; + unlisten?.(); + }; + }, []); + useEffect(() => { + if (!selectedExchangeId) { + setSelectedExchangeDetails(null); + return; + } + + let cancelled = false; + setIsLoadingSelectedExchange(true); + invoke('get_captured_exchange', { id: selectedExchangeId }) + .then((exchange) => { + if (!cancelled) setSelectedExchangeDetails(exchange); + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => { + if (!cancelled) setIsLoadingSelectedExchange(false); + }); + + return () => { + cancelled = true; + }; + }, [selectedExchangeId, selectedExchangeSummary?.timestampMs]); + + useEffect(() => { + void refreshAllProcesses(); + const interval = window.setInterval(() => void refreshAllProcesses(), 3000); return () => window.clearInterval(interval); }, []); + useEffect(() => { + window.localStorage.setItem(HIDDEN_HOSTS_STORAGE_KEY, JSON.stringify(hiddenHosts)); + }, [hiddenHosts]); + + useEffect(() => { + if (!hostContextMenu) return; + const close = () => setHostContextMenu(null); + window.addEventListener('click', close); + window.addEventListener('contextmenu', close); + return () => { + window.removeEventListener('click', close); + window.removeEventListener('contextmenu', close); + }; + }, [hostContextMenu]); + + function hideHost(rule: IgnoreRule) { + setHiddenHosts((prev) => (prev.some((r) => sameHostRule(r, rule)) ? prev : [...prev, rule])); + } + + function unhideHost(rule: IgnoreRule) { + setHiddenHosts((prev) => prev.filter((r) => !sameHostRule(r, rule))); + } + + function copyToClipboard(text: string) { + void navigator.clipboard.writeText(text).catch(() => {}); + } + + function contextMenuExchange(): CapturedExchange | null { + if (!hostContextMenu) return null; + return selectedExchangeDetails?.id === hostContextMenu.exchange.id + ? selectedExchangeDetails + : hostContextMenu.exchange; + } + + async function ignoreHost(rule: IgnoreRule) { + setError(null); + try { + setIgnoredHosts( + await invoke('add_ignored_host', { host: rule.host, method: rule.method }), + ); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + async function unignoreHost(rule: IgnoreRule) { + setError(null); + try { + setIgnoredHosts( + await invoke('remove_ignored_host', { host: rule.host, method: rule.method }), + ); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + async function clearTraffic() { await invoke('clear_captured_exchanges'); - setExchanges([]); - setSelectedExchangeId(null); + const pinned = exchanges.filter((exchange) => exchange.pinned); + setExchanges(pinned); + if (selectedExchangeId && !pinned.some((exchange) => exchange.id === selectedExchangeId)) { + setSelectedExchangeId(null); + setSelectedExchangeDetails(null); + } } - function exportSelectedCapture() { + async function setExchangePinned(exchange: CapturedExchange, pinned: boolean) { + setError(null); + try { + await invoke('set_captured_exchange_pinned', { id: exchange.id, pinned }); + setExchanges((prev) => prev.map((item) => (item.id === exchange.id ? { ...item, pinned } : item))); + if (selectedExchangeDetails?.id === exchange.id) { + setSelectedExchangeDetails({ ...selectedExchangeDetails, pinned }); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + async function deleteExchange(exchange: CapturedExchange) { + setError(null); + try { + await invoke('delete_captured_exchange', { id: exchange.id }); + setExchanges((prev) => prev.filter((item) => item.id !== exchange.id)); + if (selectedExchangeId === exchange.id) { + setSelectedExchangeId(null); + setSelectedExchangeDetails(null); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + async function clearAppLogs() { + await invoke('clear_app_logs'); + setAppLogs([]); + } + + async function exportSelectedCapture() { if (!selectedExchange) { return; } - downloadJson(`openintercept-capture-${selectedExchange.id}.json`, { + const capture = selectedExchangeDetails?.id === selectedExchange.id + ? selectedExchangeDetails + : await invoke('get_captured_exchange', { id: selectedExchange.id }); + if (!capture) return; + + downloadJson(`proctap-capture-${selectedExchange.id}.json`, { exportedAt: new Date().toISOString(), - capture: selectedExchange, + capture, }); } - function exportFilteredCaptures() { + async function exportFilteredCaptures() { if (filteredExchanges.length === 0) { return; } - downloadJson(`openintercept-captures-${timestampForFilename()}.json`, { + const ids = new Set(filteredExchanges.map((exchange) => exchange.id)); + const captures = (await invoke('list_captured_exchanges')) + .filter((exchange) => ids.has(exchange.id)); + + downloadJson(`proctap-captures-${timestampForFilename()}.json`, { exportedAt: new Date().toISOString(), - count: filteredExchanges.length, - captures: filteredExchanges, + count: captures.length, + captures, }); } - async function attachSelectedProcess() { - if (selectedPid === null) { + async function addCapture() { + setError(null); + + const target = captureTargetInput.trim(); + if (!target) return; + + if (matchingProcesses.length > 1 && duplicateMatches.length === 0) { + setError(`All currently running ${target} instances are already captured.`); return; } + const validSelectedPids = selectedPids.filter((pid) => duplicateMatches.some((process) => process.pid === pid)); + const pidsToCapture = matchingProcesses.length > 1 + ? validSelectedPids.length > 0 + ? validSelectedPids + : duplicateMatches.length === 1 + ? [duplicateMatches[0].pid] + : [] + : [null]; + + if (pidsToCapture.length === 0) { + setError('Pick at least one process instance to capture.'); + return; + } + + setIsAddingCapture(true); + try { + const results: StartCaptureResult[] = []; + for (const pid of pidsToCapture) { + const alreadyActive = captureSessions.some( + (s) => s.target.toLowerCase() === target.toLowerCase() && s.pid === pid, + ); + if (alreadyActive) continue; + + const selectedProcess = pid != null ? duplicateMatches.find((p) => p.pid === pid) : null; + const displayName = selectedProcess?.javaArtifactName ?? selectedProcess?.javaMainClass ?? undefined; + results.push(await invoke('start_capture', { target, pid, displayName })); + } + + if (results.length === 0) { + setError(`Already capturing the selected ${target} instance${pidsToCapture.length > 1 ? 's' : ''}.`); + return; + } + + setCaptureSessions((prev) => [...prev, ...results.map((result) => result.session)]); + setTrustNotice(results.map((result) => result.trustNotice).filter(Boolean).join('\n') || null); + setCaptureTargetInput(''); + setSelectedPids([]); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsAddingCapture(false); + } + } + + async function stopCaptureSession(target: string, pid: number | null) { + setError(null); + try { + setCaptureSessions(await invoke('stop_capture', { target, pid })); + setTrustNotice(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + async function toggleExplicitProxy() { setError(null); - setAttachResult(null); - setIsAttaching(true); + setIsTogglingExplicitProxy(true); try { - const result = await invoke('attach_java_process', { pid: selectedPid }); - setAttachResult(result); + if (explicitProxyStatus?.running) { + setExplicitProxyStatus(await invoke('stop_explicit_proxy')); + setExplicitProxyTrustNotice(null); + } else { + const port = Number(explicitProxyPortInput); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + setError('Explicit proxy port must be a number between 1 and 65535.'); + return; + } + const status = await invoke('start_explicit_proxy', { port }); + setExplicitProxyStatus(status); + setExplicitProxyTrustNotice(status.trustNotice); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsTogglingExplicitProxy(false); + } + } + + async function launchProxiedBrowser() { + setError(null); + setIsLaunchingBrowser(true); - if (!result.success) { - setError(result.stderr || result.stdout || `Attach failed for PID ${selectedPid}`); + try { + let port = explicitProxyStatus?.running ? explicitProxyStatus.port : null; + if (port == null) { + const requestedPort = Number(explicitProxyPortInput); + if (!Number.isInteger(requestedPort) || requestedPort <= 0 || requestedPort > 65535) { + setError('Explicit proxy port must be a number between 1 and 65535.'); + return; + } + const status = await invoke('start_explicit_proxy', { port: requestedPort }); + setExplicitProxyStatus(status); + setExplicitProxyTrustNotice(status.trustNotice); + port = status.port; + } + if (port == null) { + setError('Explicit proxy did not report a listening port.'); + return; } + await invoke('launch_proxied_browser', { url: browserUrlInput, port }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsLaunchingBrowser(false); + } + } + + async function saveFaultRule(rule: FaultRule) { + setError(null); + try { + setFaultRules(await invoke('upsert_fault_rule', { rule })); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + async function toggleFaultRule(rule: FaultRule) { + await saveFaultRule({ ...rule, enabled: !rule.enabled }); + } + + async function deleteFaultRule(id: string) { + setError(null); + try { + setFaultRules(await invoke('delete_fault_rule', { id })); } catch (err) { setError(err instanceof Error ? err.message : String(err)); + } + } + + async function addFaultRule() { + if (!ruleDraft.name.trim()) { + setError('Rule name must not be empty.'); + return; + } + + setIsAddingRule(true); + try { + await saveFaultRule({ + id: crypto.randomUUID(), + name: ruleDraft.name.trim(), + enabled: true, + probability: probabilityFromDraft(ruleDraft), + target: ruleDraft.target, + match: { + host: ruleDraft.host.trim() || null, + method: ruleDraft.method || null, + pathPattern: ruleDraft.pathPattern.trim() || null, + headerEquals: null, + }, + action: buildActionFromDraft(ruleDraft), + }); + setRuleDraft(emptyRuleDraft()); + setRuleFormOpen(false); } finally { - setIsAttaching(false); + setIsAddingRule(false); } } + const uniqueProcessNames = [...new Set(allProcesses.map((p) => p.name))].sort(); + const capturedHosts = [...new Set(exchanges.map(hostOf).filter((h): h is string => Boolean(h)))].sort(); + const capturedPids = new Set(captureSessions.map((session) => session.pid).filter((pid): pid is number => pid !== null)); + const isTargetJava = allProcesses.some( + (p) => p.name.toLowerCase() === captureTargetInput.trim().toLowerCase() && p.isJava, + ); + const matchingProcesses = allProcesses + .filter((p) => p.name.toLowerCase() === captureTargetInput.trim().toLowerCase()); + const duplicateMatches = matchingProcesses + .filter((p) => !capturedPids.has(p.pid)) + .sort((a, b) => { + if (!!a.javaArtifactName !== !!b.javaArtifactName) return a.javaArtifactName ? -1 : 1; + if (!!a.javaMainClass !== !!b.javaMainClass) return a.javaMainClass ? -1 : 1; + if (!!a.windowTitle !== !!b.windowTitle) return a.windowTitle ? -1 : 1; + if (!!a.parentName !== !!b.parentName) return a.parentName ? -1 : 1; + const parentCompare = (a.parentName ?? '').localeCompare(b.parentName ?? ''); + if (parentCompare !== 0) return parentCompare; + return a.pid - b.pid; + }); + const needsPidChoice = duplicateMatches.length > 1; + const allMatchingPidsCaptured = matchingProcesses.length > 1 && duplicateMatches.length === 0; + const validSelectedPids = selectedPids.filter((pid) => duplicateMatches.some((process) => process.pid === pid)); + const hasValidSelectedPids = validSelectedPids.length > 0; + + function toggleSelectedPid(pid: number) { + setSelectedPids((prev) => prev.includes(pid) ? prev.filter((selected) => selected !== pid) : [...prev, pid]); + } + + const startResize = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + const container = trafficGridRef.current; + const list = exchangeListRef.current; + if (!container || !list) return; + + let frame: number | null = null; + let pendingPercent = splitPercentRef.current; + + const applyPendingPercent = () => { + frame = null; + list.style.width = `${pendingPercent}%`; + }; + + const onMouseMove = (ev: MouseEvent) => { + const rect = container.getBoundingClientRect(); + const percent = ((ev.clientX - rect.left) / rect.width) * 100; + pendingPercent = Math.max(20, Math.min(75, percent)); + splitPercentRef.current = pendingPercent; + if (frame === null) { + frame = window.requestAnimationFrame(applyPendingPercent); + } + }; + + const onMouseUp = () => { + if (frame !== null) { + window.cancelAnimationFrame(frame); + applyPendingPercent(); + } + setSplitPercent(splitPercentRef.current); + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + }; + + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }, []); + return (
-
-
-

Local-first HTTP inspector

-

OpenIntercept

-

- Inspect HTTP and HTTPS traffic from Java processes without modifying requests or responses. -

+
+
+

ProcTap

- -
- {error ?
{error}
: null} +
+ { + setCaptureTargetInput(e.target.value); + setSelectedPids([]); + }} + /> + + {uniqueProcessNames.map((name) => ( + + {isTargetJava ? JVM : null} + +
-
-
-
- Proxy - {proxyStatus?.running ? 'Running' : 'Stopped'} + {matchingProcesses.length > 1 && duplicateMatches.length > 0 ? ( +
+ + {duplicateMatches.length} available + {matchingProcesses.length > duplicateMatches.length ? `, ${matchingProcesses.length - duplicateMatches.length} already captured` : ''} + {' '}— {needsPidChoice ? 'pick one or more:' : 'will be captured:'} + + {needsPidChoice ? ( + + ) : null} + {duplicateMatches.map((p) => ( + + ))} +
+ ) : null} + {allMatchingPidsCaptured ? ( +
+ All running {captureTargetInput.trim()} instances are already captured.
- {proxyStatus?.bindAddress ?? '127.0.0.1:8877'} + ) : null} + + {captureSessions.length > 0 ? ( +
+ {captureSessions.map((s) => ( + + ))} +
+ ) : null} + +
+ + Explicit proxy + setExplicitProxyPortInput(e.target.value)} + disabled={explicitProxyStatus?.running || isTogglingExplicitProxy} + />
-
-
- Local CA + +
+ setBrowserUrlInput(e.target.value)} + placeholder="http://localhost:4300" + /> + +
+ +
+ 0 ? 'pill pill-ok' : 'pill pill-off'} + title={captureSessions.length > 0 ? captureSessions.map((s) => s.target).join(', ') : 'No active capture sessions'} + > + Capture + {captureSessions.length > 0 ? `${captureSessions.length} running` : 'Idle'} + + + Explicit Proxy + {explicitProxyStatus?.running ? 'Listening' : 'Off'} + {explicitProxyStatus?.running ? 127.0.0.1:{explicitProxyStatus.port} : null} + + + Local CA {caStatus?.available ? 'Generated' : 'Missing'} -
- {caStatus?.certPath ?? 'Not generated yet'} +
-
- - {activeView === 'javaAttach' ? ( -
-
-

Java Processes

- {processes.length} -
-
- {processes.map((process) => ( - + + + + {error ?
{error}
: null} + {captureSessions.length > 1 ? ( +
+ {captureSessions.length} process captures are active through one combined local engine. While combined, mitmproxy does not expose the originating PID per flow, so new rows may be labeled as a combined local capture rather than one exact PID. +
+ ) : null} + {trustNotice ? ( +
+ +
+ {trustNotice.split('\n').map((line, index) => ( +

{line}

))} - {processes.length === 0 ?

No Java process detected.

: null}
-
+ ) : null} + {explicitProxyTrustNotice ? ( +
+ - {attachResult?.success ? ( -

Agent attached to PID {attachResult.pid}. New Java HTTP clients should use the OpenIntercept proxy.

- ) : null} -

Current attach injects JVM proxy properties. HTTPS trust and deeper client instrumentation are next milestones.

-
- ) : ( -
+
+ {explicitProxyTrustNotice.split('\n').map((line, index) => ( +

{line}

+ ))} +
+ + ) : null} + + {rulesPanelOpen ? ( + + ) : null} + + {logsPanelOpen ? ( + + ) : null} + +

Traffic

@@ -276,6 +1172,14 @@ export function App() { value={search} onChange={(e) => setSearch(e.target.value)} /> +
{methods.map((m) => (
-
-
+ {hiddenHosts.length > 0 || ignoredHosts.length > 0 ? ( +
+
+ {hiddenHosts.length > 0 ? ( + + Hidden {hiddenHosts.length} + + ) : null} + {ignoredHosts.length > 0 ? ( + + Ignored {ignoredHosts.length} + + ) : null} + +
+ {hostFiltersExpanded ? ( +
+ {hiddenHosts.length > 0 ? ( +
+ Hidden: + {hiddenHosts.map((rule) => ( + + ))} +
+ ) : null} + {ignoredHosts.length > 0 ? ( +
+ Ignored: + {ignoredHosts.map((rule) => ( + + ))} +
+ ) : null} +
+ ) : null} +
+ ) : null} +
+
{filteredExchanges.length > 0 ? filteredExchanges.map((exchange) => ( - + {exchange.timestampMs ? formatTime(exchange.timestampMs) : '-'} + + + + +
)) : (

No exchanges match the current filters.

)}
+ {selectedExchange ?
: null} {selectedExchange ? ( - + ) : null}
) : (
No traffic captured yet -

Try an HTTP request through the proxy, for example with curl using 127.0.0.1:8877.

+

Pick a process above and click Add capture (you can add several at once), then generate traffic from those processes.

)}
- )} + + {hostContextMenu ? ( +
e.stopPropagation()} + > +
+ {hostContextMenu.host} + {hostContextMenu.path ? {hostContextMenu.path} : null} +
+
+ + setHostContextMenu((prev) => (prev ? { ...prev, pattern: e.target.value } : prev)) + } + onKeyDown={(e) => { + if (e.key === 'Escape') setHostContextMenu(null); + }} + /> + +
+ + + + + + + + + + +
+ ) : null}
); } +function AppLogsPanel({ + logs, + totalLogs, + search, + setSearch, + onClear, +}: { + logs: AppLogEntry[]; + totalLogs: number; + search: string; + setSearch: (search: string) => void; + onClear: () => void; +}) { + const visibleLogs = logs.slice(-300).reverse(); + + return ( +
+
+

Backend Logs

+ + {logs.length !== totalLogs ? `${logs.length} of ${totalLogs}` : `${totalLogs} lines`} + {logs.length > visibleLogs.length ? `, showing latest ${visibleLogs.length}` : ''} + +
+ +
+
+
+ setSearch(e.target.value)} + /> +
+ {visibleLogs.length === 0 ? ( +

No backend logs yet.

+ ) : ( +
+ {visibleLogs.map((entry) => ( +
+ {formatTime(entry.timestampMs)} + {entry.level} + {entry.source} + {entry.message} +
+ ))} +
+ )} +
+ ); +} + +function FaultRulesPanel({ + rules, + formOpen, + setFormOpen, + draft, + setDraft, + onAdd, + isAdding, + onToggle, + onDelete, + hostSuggestions, +}: { + rules: FaultRule[]; + formOpen: boolean; + setFormOpen: (open: boolean) => void; + draft: RuleDraft; + setDraft: (draft: RuleDraft) => void; + onAdd: () => void; + isAdding: boolean; + onToggle: (rule: FaultRule) => void; + onDelete: (id: string) => void; + hostSuggestions: string[]; +}) { + return ( +
+
+

Fault Rules

+ {rules.filter((r) => r.enabled).length} of {rules.length} enabled +
+ +
+
+ + {formOpen ? ( +
+
+ setDraft({ ...draft, name: e.target.value })} + /> + +
+
+ setDraft({ ...draft, host: e.target.value })} + /> + + {hostSuggestions.map((host) => ( + + + setDraft({ ...draft, pathPattern: e.target.value })} + /> + setDraft({ ...draft, probabilityPercent: e.target.value })} + /> +
+
+ + {draft.actionKind === 'delay' ? ( + setDraft({ ...draft, delayMs: e.target.value })} + /> + ) : null} + {draft.actionKind === 'force_status' ? ( + <> + setDraft({ ...draft, status: e.target.value })} + /> + setDraft({ ...draft, body: e.target.value })} + /> + + ) : null} + {draft.actionKind === 'throttle' ? ( + setDraft({ ...draft, bytesPerSecond: e.target.value })} + /> + ) : null} + {draft.actionKind === 'disconnect_stream' ? ( + <> + setDraft({ ...draft, disconnectAfterMs: e.target.value })} + /> + + + ) : null} +
+ +
+ ) : null} + + {rules.length === 0 ? ( +

+ No fault rules yet. Rules inject delay, forced errors, aborts, throttling, or stream disconnects into matching requests or + responses across every active capture session. +

+ ) : ( +
+ {rules.map((rule) => ( +
+ + {rule.name} + {rule.target} + + {describeProbability(rule.probability)} + + {describeMatch(rule.match)} + {describeAction(rule.action)} + +
+ ))} +
+ )} +
+ ); +} + +function CopyButton({ text, className }: { text: string; className?: string }) { + const [done, setDone] = useState(false); + + function handleClick(e: React.MouseEvent) { + e.stopPropagation(); + navigator.clipboard.writeText(text).then(() => { + setDone(true); + setTimeout(() => setDone(false), 1500); + }).catch(() => {}); + } + + return ( + + ); +} + type DetailTab = 'overview' | 'request' | 'response' | 'raw'; -function ExchangeDetail({ exchange }: { exchange: CapturedExchange }) { +function ExchangeDetail({ exchange, loadingBodies }: { exchange: CapturedExchange; loadingBodies: boolean }) { const [tab, setTab] = useState('overview'); return (
-

{exchange.method} {exchange.url}

+
+

{exchange.method} {exchange.url}

+ +
{(['overview', 'request', 'response', 'raw'] as DetailTab[]).map((t) => ( ))}
- {tab === 'overview' && ( - <> -
-
Status
-
{exchange.status ?? 'Unknown'}
-
Duration
-
{exchange.durationMs ?? 0} ms
-
Request body
-
{exchange.requestBody?.length ?? 0} bytes
-
Response body
-
{exchange.responseBody?.length ?? 0} bytes
-
- {exchange.error ? : null} - - )} + {tab === 'overview' && } + {loadingBodies ?

Loading bodies...

: null} {tab === 'request' && ( <> + {(() => { + try { + const hasParams = new URL(exchange.url).searchParams.size > 0; + if (!hasParams) return null; + return ( + <> +

Query Parameters

+ + + ); + } catch { + return null; + } + })()}

Headers

Body

- + {loadingBodies + ?

Loading request body...

+ : } )} {tab === 'response' && ( @@ -386,16 +1841,220 @@ function ExchangeDetail({ exchange }: { exchange: CapturedExchange }) {

Headers

Body

- + {loadingBodies ? ( +

Loading response body...

+ ) : ( + + )} )} {tab === 'raw' && ( - + loadingBodies ?

Loading raw capture...

: )}
); } +const KEY_REQUEST_HEADERS = new Set([ + 'content-type', 'authorization', 'accept', 'cookie', + 'origin', 'referer', 'x-api-key', 'x-auth-token', 'x-request-id', 'x-correlation-id', +]); + +const KEY_RESPONSE_HEADERS = new Set([ + 'content-type', 'cache-control', 'location', 'set-cookie', + 'www-authenticate', 'x-ratelimit-limit', 'x-ratelimit-remaining', + 'x-request-id', 'x-correlation-id', +]); + +function filterKeyHeaders(headers: HeaderPair[], type: 'request' | 'response'): HeaderPair[] { + const keys = type === 'request' ? KEY_REQUEST_HEADERS : KEY_RESPONSE_HEADERS; + return headers.filter((h) => keys.has(h.name.toLowerCase())); +} + +const OVERVIEW_PREVIEW_CHARS = 600; + +function BodyOverviewPreview({ body, contentType }: { body: number[]; contentType: string }) { + const text = decodeBody(body); + + if (text === null) { + return

{body.length} bytes · binary

; + } + + if (contentType.includes('application/x-www-form-urlencoded')) { + const params = [...new URLSearchParams(text).entries()]; + const visible = params.slice(0, 8); + return ( +
+ +
+ {visible.map(([key, value], i) => ( +
+ {key} + {value || empty} + +
+ ))} + {params.length > 8 && ( +

+{params.length - 8} more fields — see Request tab

+ )} +
+
+ ); + } + + const displayText = contentType.includes('json') ? prettyJson(text) : text; + const truncated = displayText.length > OVERVIEW_PREVIEW_CHARS; + const preview = truncated ? displayText.slice(0, OVERVIEW_PREVIEW_CHARS) : displayText; + + return ( +
+ +
+        {preview}
+        {truncated ? {'\n'}… truncated — see tab for full body : null}
+      
+
+ ); +} + +const PHASE_COLORS = { + send: '#3987e5', + wait: '#9085e9', + receive: '#199e70', +} as const; + +type PhaseKey = keyof typeof PHASE_COLORS; + +function PhaseTimingBar({ exchange }: { exchange: CapturedExchange }) { + const candidates: { key: PhaseKey; label: string; ms: number | null | undefined }[] = [ + { key: 'send', label: 'Send', ms: exchange.sendDurationMs }, + { key: 'wait', label: 'Wait', ms: exchange.waitDurationMs }, + { key: 'receive', label: 'Receive', ms: exchange.receiveDurationMs }, + ]; + const phases = candidates.filter( + (p): p is { key: PhaseKey; label: string; ms: number } => p.ms != null, + ); + const total = phases.reduce((sum, p) => sum + p.ms, 0); + + if (phases.length === 0 || total <= 0) { + return null; + } + + return ( +
+
+ {phases.map((p) => ( + + ))} +
+
+ {phases.map((p) => ( + + + {p.label} {p.ms} ms + + ))} +
+
+ ); +} + +function OverviewTab({ exchange }: { exchange: CapturedExchange }) { + const keyReqHeaders = filterKeyHeaders(exchange.requestHeaders, 'request'); + const keyResHeaders = filterKeyHeaders(exchange.responseHeaders, 'response').filter( + (h) => h.name.toLowerCase() !== 'set-cookie' || !isClearingCookie(h.value), + ); + const reqContentType = headerValue(exchange.requestHeaders, 'content-type') ?? ''; + const resContentType = headerValue(exchange.responseHeaders, 'content-type') ?? ''; + + return ( +
+
+ + {exchange.status ?? '—'} + + {exchange.streaming ? live stream : null} + {exchange.responseBodyTruncated ? body truncated : null} + + {exchange.durationMs != null ? `${exchange.durationMs} ms` : null} + {exchange.timestampMs ? ` · ${formatDateTime(exchange.timestampMs)}` : null} + + {exchange.processName ? ( + + {exchange.processName} + {exchange.processPid != null ? ` · PID ${exchange.processPid}` : ''} + + ) : null} +
+ + + +
+
+

+ Request + {exchange.requestBody != null && ( + {formatBytes(exchange.requestBody.length)} + )} +

+ {keyReqHeaders.length > 0 && ( +
+ {keyReqHeaders.map((h, i) => ( +
+ {h.name} + {formatOverviewHeaderValue(h.name, h.value)} + +
+ ))} +
+ )} + + {exchange.requestBody && exchange.requestBody.length > 0 + ? + :

No body

} +
+ +
+

+ Response + {exchange.responseBody != null && ( + {formatBytes(exchange.responseBody.length)} + )} +

+ {keyResHeaders.length > 0 && ( +
+ {keyResHeaders.map((h, i) => ( +
+ {h.name} + {formatOverviewHeaderValue(h.name, h.value)} + +
+ ))} +
+ )} + {exchange.responseBody && exchange.responseBody.length > 0 + ? + :

No body

} +
+
+ + {exchange.error ? : null} +
+ ); +} + function ErrorDiagnostic({ error }: { error: string }) { const diagnostic = parseErrorDiagnostic(error); @@ -489,6 +2148,41 @@ function RawViewer({ exchange }: { exchange: CapturedExchange }) { ); } +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +function formatOverviewHeaderValue(name: string, value: string): string { + if (name.toLowerCase() === 'set-cookie') { + return value.split(';')[0].trim(); + } + return value; +} + +function isClearingCookie(setCookieValue: string): boolean { + const [nameValue, ...directives] = setCookieValue.split(';').map((p) => p.trim()); + const eqIdx = nameValue.indexOf('='); + const rawVal = eqIdx >= 0 ? nameValue.slice(eqIdx + 1) : ''; + const val = rawVal.replace(/^"(.*)"$/, '$1'); + if (val === '') return true; + if (directives.some((d) => d.toLowerCase() === 'max-age=0')) return true; + return false; +} + +function formatTime(ms: number): string { + const d = new Date(ms); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +function formatDateTime(ms: number): string { + const d = new Date(ms); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + function statusClass(status?: number | null): string { if (!status) return 'status-none'; if (status < 300) return 'status-2xx'; @@ -523,21 +2217,211 @@ function timestampForFilename() { return new Date().toISOString().replace(/[:.]/g, '-'); } -function BodyViewer({ body, headers }: { body?: number[] | null; headers: HeaderPair[] }) { +function BodyViewer({ + body, + headers, + streaming = false, + truncated = false, +}: { + body?: number[] | null; + headers: HeaderPair[]; + streaming?: boolean; + truncated?: boolean; +}) { if (!body || body.length === 0) { - return

No body captured.

; + return

{streaming ? 'Waiting for streamed body chunks…' : 'No body captured.'}

; } const text = decodeBody(body); if (text === null) { - return
Binary body, {body.length} bytes captured.
; + return ( +
+ +
Binary body, {body.length} bytes captured.
+
+ ); } const contentType = headerValue(headers, 'content-type') ?? ''; + + if (contentType.toLowerCase().includes('text/event-stream')) { + return ( +
+ + + +
+ ); + } + + if (contentType.includes('application/x-www-form-urlencoded')) { + return ( +
+ + + +
+ ); + } + const displayText = contentType.includes('json') ? prettyJson(text) : text; - return
{displayText}
; + return ( +
+ + +
{displayText}
+
+ ); +} + +type SseEvent = { + index: number; + id: string | null; + event: string | null; + retry: string | null; + data: string; + comments: string[]; + raw: string; + incomplete: boolean; +}; + +function SseBodyViewer({ text, truncated }: { text: string; truncated: boolean }) { + const events = parseSseEvents(text); + const visibleEvents = events.slice(-100); + + if (events.length === 0) { + return
{text}
; + } + + return ( +
+
+ {events.length} event{events.length > 1 ? 's' : ''} + {events.length > visibleEvents.length ? showing latest {visibleEvents.length} : null} + {truncated ? older bytes were truncated : null} +
+
+ {visibleEvents.map((event) => ( +
+
+ #{event.index} + {event.id ? id {event.id} : null} + event {event.event ?? 'message'} + {event.retry ? retry {event.retry} ms : null} + {event.incomplete ? incomplete : null} +
+ {event.comments.length > 0 ? ( +
+ {event.comments.map((comment, index) => : {comment})} +
+ ) : null} +
{event.data || event.raw}
+
+ ))} +
+
+ ); +} + +function parseSseEvents(text: string): SseEvent[] { + const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const blocks = normalized.split('\n\n'); + const trailingIncomplete = !normalized.endsWith('\n\n'); + + return blocks + .map((block, blockIndex) => ({ block, blockIndex })) + .filter(({ block }) => block.length > 0) + .map(({ block, blockIndex }, eventIndex) => { + let id: string | null = null; + let event: string | null = null; + let retry: string | null = null; + const dataLines: string[] = []; + const comments: string[] = []; + + for (const line of block.split('\n')) { + if (line.startsWith(':')) { + comments.push(line.slice(1).trimStart()); + continue; + } + + const separator = line.indexOf(':'); + const field = separator === -1 ? line : line.slice(0, separator); + const value = separator === -1 ? '' : line.slice(separator + 1).replace(/^ /, ''); + + if (field === 'id') id = value; + else if (field === 'event') event = value; + else if (field === 'retry') retry = value; + else if (field === 'data') dataLines.push(value); + } + + return { + index: eventIndex + 1, + id, + event, + retry, + data: dataLines.join('\n'), + comments, + raw: block, + incomplete: trailingIncomplete && blockIndex === blocks.length - 1, + }; + }); +} + +function BodyCaptureNotice({ streaming, truncated }: { streaming: boolean; truncated: boolean }) { + if (!streaming && !truncated) { + return null; + } + return ( +
+ {streaming ? Live stream: body updates while chunks arrive. : null} + {truncated ? Body truncated: showing the most recent captured bytes. : null} +
+ ); +} + +function QueryParamsViewer({ url }: { url: string }) { + let params: [string, string][] = []; + try { + params = [...new URL(url).searchParams.entries()]; + } catch { + return null; + } + + if (params.length === 0) return null; + + return ( +
+ {params.map(([key, value], index) => ( +
+ {key} + {value || empty} + +
+ ))} +
+ ); +} + +function FormFieldsViewer({ text }: { text: string }) { + const params = [...new URLSearchParams(text).entries()]; + + if (params.length === 0) { + return
{text}
; + } + + return ( +
+ {params.map(([key, value], index) => ( +
+ {key} + {value || empty} + +
+ ))} +
+ ); } function headerValue(headers: HeaderPair[], name: string) { @@ -578,6 +2462,7 @@ function HeaderList({ headers }: { headers: HeaderPair[] }) {
{header.name} {header.value} +
))} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 238664c..08750db 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -10,10 +10,15 @@ box-sizing: border-box; } +html, +body { + height: 100%; +} + body { margin: 0; min-width: 320px; - min-height: 100vh; + overflow: hidden; } button { @@ -21,22 +26,28 @@ button { } .shell { - min-height: 100vh; - padding: 32px; + height: 100vh; + padding: 20px 32px; + display: flex; + flex-direction: column; + overflow: hidden; background: radial-gradient(circle at top left, rgba(25, 118, 210, 0.28), transparent 34rem), linear-gradient(135deg, #071018 0%, #0b1824 100%); } -.hero { +.app-header { display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 24px; - margin-bottom: 24px; + align-items: center; + flex-wrap: wrap; + gap: 20px; + margin-bottom: 16px; +} + +.app-title { + flex-shrink: 0; } -.eyebrow, .label, .hint, .empty, @@ -44,14 +55,6 @@ small { color: #8ba6bc; } -.eyebrow { - margin: 0 0 8px; - text-transform: uppercase; - letter-spacing: 0.12em; - font-size: 12px; - font-weight: 700; -} - h1, h2, p { @@ -59,9 +62,10 @@ p { } h1 { - margin-bottom: 12px; - font-size: clamp(40px, 7vw, 76px); - line-height: 0.95; + margin-bottom: 0; + font-size: 22px; + line-height: 1; + white-space: nowrap; } h2 { @@ -69,12 +73,6 @@ h2 { font-size: 18px; } -.summary { - max-width: 620px; - color: #afc4d6; - font-size: 18px; -} - .primary { border: 0; border-radius: 999px; @@ -95,34 +93,9 @@ h2 { margin-top: 16px; } -.view-tabs { - display: inline-flex; - gap: 8px; - margin-bottom: 16px; - border: 1px solid rgba(145, 183, 210, 0.16); - border-radius: 999px; - padding: 6px; - background: rgba(9, 24, 36, 0.72); -} - -.tab { - border: 0; - border-radius: 999px; - padding: 10px 16px; - color: #afc4d6; - background: transparent; - cursor: pointer; - font-weight: 800; -} - -.tab.active { - color: #03111c; - background: #62d0ff; -} - .error, .success, -.status-card, +.notice, .panel { border: 1px solid rgba(145, 183, 210, 0.16); border-radius: 24px; @@ -137,6 +110,48 @@ h2 { background: rgba(130, 24, 24, 0.42); } +.notice { + position: relative; + margin-bottom: 16px; + padding: 16px 40px 16px 16px; + color: #d9ecff; + background: rgba(24, 82, 130, 0.32); +} + +.notice-body { + max-height: 160px; + overflow-y: auto; +} + +.notice-close { + position: absolute; + top: 10px; + right: 10px; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: #d9ecff; + cursor: pointer; + line-height: 1; + opacity: 0.7; +} + +.notice-close:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.12); +} + +.notice p { + margin: 0; +} + +.notice p + p { + margin-top: 4px; +} + .success { margin: 12px 0 0; padding: 12px; @@ -144,111 +159,266 @@ h2 { background: rgba(28, 120, 79, 0.28); } -.status-card { - display: grid; - grid-template-columns: minmax(240px, 0.7fr) minmax(320px, 1.3fr); - gap: 16px; - margin-bottom: 16px; - padding: 18px; +.status-pills { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; + overflow: hidden; } -.status-item { +.pill { display: flex; align-items: center; - justify-content: space-between; - gap: 18px; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + border: 1px solid rgba(145, 183, 210, 0.16); + background: rgba(9, 24, 36, 0.72); + font-size: 12px; min-width: 0; + overflow: hidden; + white-space: nowrap; } -.status-item div { - display: grid; - gap: 4px; +.pill code { + font-size: 11px; } -code { - color: #9ee7ff; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.pill-label { + color: #8ba6bc; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 700; + font-size: 10px; } -.grid { - display: grid; - grid-template-columns: minmax(280px, 430px) 1fr; - gap: 16px; +.pill strong { + color: #cfe3f2; } -.panel { - min-height: 480px; - padding: 18px; +.pill-ok strong { + color: #4ac97a; } -.attach-panel, -.full-width-panel { - min-height: 560px; +.pill-off strong { + color: #e08a4a; } -.panel-header { +.capture-picker { display: flex; align-items: center; - justify-content: space-between; - margin-bottom: 16px; + gap: 8px; + flex-shrink: 0; } -.panel-header span { - color: #8ba6bc; +.process-target-input { + width: 220px; + border: 1px solid rgba(145, 183, 210, 0.24); + border-radius: 999px; + padding: 9px 14px; + color: #cfe3f2; + background: rgba(9, 24, 36, 0.72); + font: inherit; font-size: 13px; } -.panel-actions { +.process-target-input:focus { + border-color: rgba(98, 208, 255, 0.55); + outline: none; +} + +.process-target-input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.pid-picker { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + flex-basis: 100%; + max-height: 108px; + overflow-y: auto; +} + +.pid-picker-label { + color: #8ba6bc; + font-size: 12px; + white-space: nowrap; +} + +.active-sessions { display: flex; + align-items: center; flex-wrap: wrap; + gap: 6px; + flex-shrink: 0; +} + +.active-session-chip { + border-color: rgba(74, 201, 122, 0.4); + color: #9ee7c2; +} + +.active-session-chip:hover { + border-color: rgba(240, 96, 96, 0.6); + color: #f0a0a0; +} + +.jvm-badge { + background: rgba(98, 208, 255, 0.16); + border-color: rgba(98, 208, 255, 0.4); + color: #9ee7ff; + font-weight: 800; +} + +.explicit-proxy-toggle { + display: flex; + align-items: center; gap: 8px; - justify-content: flex-end; + flex-shrink: 0; + padding: 6px 12px; + border: 1px solid rgba(145, 183, 210, 0.16); + border-radius: 999px; + background: rgba(9, 24, 36, 0.5); } -.process-list { - display: grid; - gap: 10px; +.switch { + position: relative; + display: inline-block; + width: 34px; + height: 20px; + flex-shrink: 0; } -.process { - display: grid; - grid-template-columns: 72px 1fr; - gap: 14px; - width: 100%; - border: 1px solid rgba(145, 183, 210, 0.14); - border-radius: 18px; - padding: 14px; - color: inherit; - background: rgba(255, 255, 255, 0.03); - text-align: left; +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.switch-slider { + position: absolute; + inset: 0; cursor: pointer; + background: rgba(145, 183, 210, 0.28); + border-radius: 999px; + transition: background 0.15s ease; } -.process:hover, -.process.selected { - border-color: rgba(98, 208, 255, 0.8); - background: rgba(98, 208, 255, 0.08); +.switch-slider::before { + content: ''; + position: absolute; + height: 14px; + width: 14px; + left: 3px; + bottom: 3px; + background: #d9e6f2; + border-radius: 50%; + transition: transform 0.15s ease; } -.pid { - color: #62d0ff; - font-weight: 800; +.switch input:checked + .switch-slider { + background: #4ac97a; } -.process span:last-child { - display: grid; - gap: 4px; - min-width: 0; +.switch input:checked + .switch-slider::before { + transform: translateX(14px); +} + +.switch input:disabled + .switch-slider { + opacity: 0.5; + cursor: not-allowed; +} + +.explicit-proxy-label { + font-size: 13px; + font-weight: 700; + color: #cfe3f2; + white-space: nowrap; +} + +.explicit-proxy-port-input { + width: 68px; + border: 1px solid rgba(145, 183, 210, 0.24); + border-radius: 8px; + padding: 6px 8px; + color: #cfe3f2; + background: rgba(9, 24, 36, 0.72); + font: inherit; + font-size: 13px; +} + +.explicit-proxy-port-input:disabled { + opacity: 0.6; + cursor: not-allowed; } -.process small { +.proxied-browser-launch { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.proxied-browser-url-input { + width: 220px; + border: 1px solid rgba(145, 183, 210, 0.24); + border-radius: 999px; + padding: 9px 14px; + color: #cfe3f2; + background: rgba(9, 24, 36, 0.72); + font: inherit; + font-size: 13px; +} + +.proxied-browser-url-input:focus { + border-color: rgba(98, 208, 255, 0.55); + outline: none; +} + +code { + color: #9ee7ff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.panel { + padding: 18px; +} + +.full-width-panel { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.panel-header span { + color: #8ba6bc; + font-size: 13px; +} + +.panel-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + justify-content: flex-end; +} + .filter-bar { display: flex; flex-wrap: wrap; @@ -306,45 +476,284 @@ code { background: rgba(98, 208, 255, 0.1); } -.traffic-panel { - display: grid; - grid-template-rows: auto auto auto 1fr; +.host-filter-bar { + display: flex; + flex-direction: column; + gap: 8px; + margin: -6px 0 14px; } -.traffic-grid { - display: grid; - grid-template-columns: minmax(360px, 0.7fr) minmax(0, 1.3fr); - gap: 14px; - min-height: 0; +.host-filter-summary, +.host-filter-details { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; } -.exchange-list, -.exchange-detail { - min-width: 0; - overflow: auto; +.host-filter-count { + cursor: default; + color: #cfe3f2; + border-color: rgba(145, 183, 210, 0.3); + background: rgba(145, 183, 210, 0.08); } -.exchange-list { - display: grid; - align-content: start; - gap: 8px; +.host-filter-group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; } -.exchange { - display: grid; - grid-template-columns: 72px minmax(0, 1fr) 54px 72px; - gap: 10px; - align-items: center; - width: 100%; - border: 1px solid rgba(145, 183, 210, 0.14); - border-radius: 14px; - padding: 10px; - color: inherit; +.host-filter-label { + color: #8ba6bc; + font-size: 12px; + font-weight: 700; +} + +.host-chip { + color: #d9e6f2; +} + +.app-logs-panel { + flex-shrink: 0; + margin-bottom: 16px; + max-height: 34vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.app-log-list { + min-height: 0; + overflow: auto; + border: 1px solid rgba(145, 183, 210, 0.12); + border-radius: 14px; + background: rgba(0, 0, 0, 0.2); +} + +.app-log-row { + display: grid; + grid-template-columns: 76px 58px 118px minmax(0, 1fr); + gap: 10px; + padding: 7px 10px; + border-bottom: 1px solid rgba(145, 183, 210, 0.08); + align-items: baseline; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 12px; + line-height: 1.35; +} + +.app-log-row:last-child { + border-bottom: 0; +} + +.app-log-time, +.app-log-source { + color: #8ba6bc; +} + +.app-log-level { + color: #62d0ff; + font-weight: 800; + text-transform: uppercase; +} + +.app-log-error .app-log-level, +.app-log-error .app-log-message { + color: #ffb4b4; +} + +.app-log-warn .app-log-level, +.app-log-warning .app-log-level { + color: #ffd08a; +} + +.app-log-message { + min-width: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: #d9e6f2; +} + +.host-context-menu { + position: fixed; + z-index: 50; + display: flex; + flex-direction: column; + min-width: 220px; + border: 1px solid rgba(145, 183, 210, 0.28); + border-radius: 10px; + background: #16232d; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); + overflow: hidden; +} + +.host-context-menu-title { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 12px 4px; + font-size: 12px; + font-weight: 700; + color: #62d0ff; + word-break: break-all; +} + +.host-context-menu-title small { + color: #8ba6bc; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 11px; + font-weight: 500; +} + +.host-context-menu-row { + display: flex; + gap: 6px; + margin: 0 12px 8px; +} + +.host-context-menu-input { + flex: 1; + min-width: 0; + border: 1px solid rgba(145, 183, 210, 0.28); + border-radius: 6px; + padding: 6px 8px; + color: #d9e6f2; + background: rgba(0, 0, 0, 0.28); + font: inherit; + font-size: 13px; +} + +.host-context-menu-input:focus, +.host-context-menu-select:focus { + border-color: rgba(98, 208, 255, 0.5); + outline: none; +} + +.host-context-menu-select { + border: 1px solid rgba(145, 183, 210, 0.28); + border-radius: 6px; + padding: 6px 8px; + color: #d9e6f2; + background: rgba(0, 0, 0, 0.28); + font: inherit; + font-size: 13px; +} + +.host-context-menu button:first-of-type { + border-top: 1px solid rgba(145, 183, 210, 0.18); +} + +.host-context-menu button:disabled { + color: #566b7c; + cursor: not-allowed; +} + +.host-context-menu button { + border: none; + background: transparent; + color: #d9e6f2; + text-align: left; + padding: 9px 12px; + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.host-context-menu button:hover { + background: rgba(98, 208, 255, 0.12); +} + +.pid-chip { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1px; + max-width: 280px; + padding: 4px 12px; +} + +.pid-chip-title { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pid-chip-pid { + font-size: 10px; + font-weight: 400; + opacity: 0.7; +} + +.pid-chip-parent { + max-width: 100%; + overflow: hidden; + color: #8ba6bc; + font-size: 10px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.traffic-panel { + display: flex; + flex-direction: column; +} + +.traffic-grid { + display: flex; + flex: 1; + min-height: 0; + gap: 0; + overflow: hidden; +} + +.exchange-list { + flex-shrink: 0; + min-width: 0; + overflow-y: auto; + padding-right: 6px; +} + +.exchange-detail { + flex: 1; + min-width: 0; + overflow-y: auto; +} + +.exchange-list { + display: grid; + align-content: start; + gap: 8px; +} + +.exchange { + display: grid; + grid-template-columns: 72px minmax(0, 1fr) auto auto 54px 72px 66px auto; + gap: 10px; + align-items: center; + width: 100%; + border: 1px solid rgba(145, 183, 210, 0.14); + border-radius: 14px; + padding: 10px; + color: inherit; background: rgba(255, 255, 255, 0.03); text-align: left; cursor: pointer; } +.exchange.is-pinned { + border-color: rgba(240, 192, 96, 0.42); + background: rgba(240, 192, 96, 0.055); +} + +.exchange.is-streaming { + border-color: rgba(93, 232, 160, 0.32); + background: rgba(93, 232, 160, 0.045); +} + .exchange:hover, .exchange.selected { border-color: rgba(98, 208, 255, 0.8); @@ -362,6 +771,45 @@ code { white-space: nowrap; } +.live-badge, +.pin-badge, +.overview-live-badge, +.overview-truncated-badge { + border-radius: 999px; + padding: 3px 8px; + font-size: 11px; + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.live-badge, +.overview-live-badge { + color: #5de8a0; + border: 1px solid rgba(93, 232, 160, 0.28); + background: rgba(93, 232, 160, 0.1); +} + +.pin-badge { + color: #f0c060; + border: 1px solid rgba(240, 192, 96, 0.28); + background: rgba(240, 192, 96, 0.1); +} + +.pin-badge-empty { + visibility: hidden; +} + +.live-badge-empty { + visibility: hidden; +} + +.overview-truncated-badge { + color: #f0c060; + border: 1px solid rgba(240, 192, 96, 0.28); + background: rgba(240, 192, 96, 0.1); +} + .status, .duration { font-size: 13px; @@ -380,6 +828,41 @@ code { .duration { color: #8ba6bc; } +.time-col { + font-size: 12px; + text-align: right; + color: #5a7a8f; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; +} + +.exchange-actions { + display: flex; + gap: 6px; + justify-content: flex-end; +} + +.row-action { + border: 1px solid rgba(145, 183, 210, 0.24); + border-radius: 999px; + padding: 4px 8px; + color: #afc4d6; + background: rgba(255, 255, 255, 0.03); + cursor: pointer; + font-size: 11px; + font-weight: 800; +} + +.row-action:hover, +.row-action.active { + color: #f0c060; + border-color: rgba(240, 192, 96, 0.5); +} + +.row-action.danger:hover { + color: #f06060; + border-color: rgba(240, 96, 96, 0.5); +} + .exchange.has-error { border-color: rgba(240, 96, 96, 0.35); background: rgba(240, 96, 96, 0.06); @@ -402,34 +885,255 @@ code { font-weight: 700; } -.secondary:hover { - border-color: rgba(98, 208, 255, 0.6); - color: #d9e6f2; +.secondary:hover { + border-color: rgba(98, 208, 255, 0.6); + color: #d9e6f2; +} + +.secondary:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.secondary:disabled:hover { + border-color: rgba(145, 183, 210, 0.3); + color: #afc4d6; +} + +.secondary.active { + border-color: #62d0ff; + color: #62d0ff; + background: rgba(98, 208, 255, 0.1); +} + +.fault-rules-panel { + margin-bottom: 16px; + flex-shrink: 0; + max-height: 36vh; + overflow: auto; +} + +.rule-form { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 16px; + padding: 12px; + border: 1px solid rgba(145, 183, 210, 0.16); + border-radius: 14px; + background: rgba(0, 0, 0, 0.18); +} + +.rule-form-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.rule-form-row input, +.rule-form-row select { + flex: 1; + min-width: 120px; + border: 1px solid rgba(145, 183, 210, 0.24); + border-radius: 8px; + padding: 8px 10px; + color: #cfe3f2; + background: rgba(9, 24, 36, 0.72); + font: inherit; + font-size: 13px; +} + +.rule-form-row input:focus, +.rule-form-row select:focus { + border-color: rgba(98, 208, 255, 0.55); + outline: none; +} + +.rule-inline-checkbox { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; + min-height: 34px; + padding: 0 10px; + color: #afc4d6; + font-size: 13px; + font-weight: 700; +} + +.rule-form-row .rule-inline-checkbox input { + flex: 0 0 auto; + min-width: 0; +} + +.rule-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.rule-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto minmax(0, 1.2fr) minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 10px 12px; + border: 1px solid rgba(145, 183, 210, 0.14); + border-radius: 12px; + background: rgba(255, 255, 255, 0.03); +} + +.rule-row.rule-disabled { + opacity: 0.5; +} + +.rule-name { + font-weight: 700; + color: #d9e6f2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rule-target-chip { + cursor: default; + text-transform: uppercase; + font-size: 10px; +} + +.rule-probability-chip { + cursor: default; + color: #f0c060; + border-color: rgba(240, 192, 96, 0.3); + background: rgba(240, 192, 96, 0.08); +} + +.rule-target-request { + border-color: rgba(240, 192, 96, 0.4); + color: #f0c060; +} + +.rule-target-response { + border-color: rgba(98, 208, 255, 0.4); + color: #62d0ff; +} + +.rule-match, +.rule-action { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + color: #afc4d6; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; +} + +.resize-handle { + flex-shrink: 0; + width: 10px; + cursor: col-resize; + display: flex; + align-items: center; + justify-content: center; } -.secondary:disabled { - cursor: not-allowed; - opacity: 0.45; +.resize-handle::after { + content: ''; + width: 2px; + height: 36px; + border-radius: 2px; + background: rgba(145, 183, 210, 0.18); + transition: background 0.15s, height 0.15s; } -.secondary:disabled:hover { - border-color: rgba(145, 183, 210, 0.3); - color: #afc4d6; +.resize-handle:hover::after { + height: 56px; + background: rgba(98, 208, 255, 0.55); } .exchange-detail { + container-type: inline-size; border: 1px solid rgba(145, 183, 210, 0.14); border-radius: 18px; padding: 16px; background: rgba(255, 255, 255, 0.025); } -.exchange-detail h3 { - margin: 0 0 12px; +.detail-url-row { + display: flex; + align-items: flex-start; + gap: 6px; + margin-bottom: 12px; +} + +.detail-url-heading { + flex: 1; + margin: 0; overflow-wrap: anywhere; font-size: 16px; } +.copy-btn { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 22px; + height: 22px; + border: none; + border-radius: 6px; + padding: 0; + background: transparent; + color: #5a7a8f; + cursor: pointer; + opacity: 0; + transition: opacity 0.12s, background 0.12s, color 0.12s; + font-size: 12px; + line-height: 1; +} + +.copy-btn.done { + opacity: 1 !important; + color: #5de8a0; +} + +.copy-btn:hover { + background: rgba(98, 208, 255, 0.1); + color: #62d0ff; + opacity: 1; +} + +.copy-btn:focus-visible { + opacity: 1; + outline: 1px solid rgba(98, 208, 255, 0.4); + outline-offset: 1px; +} + +.detail-url-row:hover .copy-btn, +.header-row:hover .copy-btn, +.overview-header-row:hover .copy-btn, +.form-field-row:hover .copy-btn { + opacity: 1; +} + +.body-with-copy { + position: relative; +} + +.body-copy-btn { + position: absolute; + top: 8px; + right: 8px; + z-index: 1; + opacity: 0; + background: rgba(7, 16, 24, 0.85); + backdrop-filter: blur(4px); +} + +.body-with-copy:hover .body-copy-btn { + opacity: 1; +} + .exchange-detail h4 { margin: 18px 0 8px; color: #afc4d6; @@ -472,6 +1176,224 @@ code { overflow-wrap: anywhere; } +.overview { + display: flex; + flex-direction: column; + gap: 14px; +} + +.overview-meta { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.overview-status-badge { + border-radius: 999px; + padding: 5px 14px; + font-size: 20px; + font-weight: 900; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; +} + +.overview-status-badge.status-2xx { color: #5de8a0; background: rgba(93, 232, 160, 0.1); border: 1px solid rgba(93, 232, 160, 0.22); } +.overview-status-badge.status-3xx { color: #f0c060; background: rgba(240, 192, 96, 0.1); border: 1px solid rgba(240, 192, 96, 0.22); } +.overview-status-badge.status-4xx { color: #f09040; background: rgba(240, 144, 64, 0.1); border: 1px solid rgba(240, 144, 64, 0.22); } +.overview-status-badge.status-5xx { color: #f06060; background: rgba(240, 96, 96, 0.1); border: 1px solid rgba(240, 96, 96, 0.22); } +.overview-status-badge.status-none { color: #8ba6bc; background: rgba(139, 166, 188, 0.08); border: 1px solid rgba(139, 166, 188, 0.2); } + +.overview-timing { + color: #8ba6bc; + font-size: 13px; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; +} + +.overview-process { + color: #9ee7ff; + font-size: 12px; + font-weight: 700; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid rgba(98, 208, 255, 0.24); + background: rgba(98, 208, 255, 0.08); +} + +.body-capture-notice { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; + color: #afc4d6; + font-size: 12px; +} + +.body-capture-notice span { + border-radius: 999px; + border: 1px solid rgba(145, 183, 210, 0.2); + background: rgba(255, 255, 255, 0.04); + padding: 4px 9px; +} + +.phase-timing { + display: flex; + flex-direction: column; + gap: 6px; +} + +.phase-timing-bar { + display: flex; + gap: 2px; + height: 6px; + border-radius: 3px; + overflow: hidden; +} + +.phase-timing-segment { + height: 100%; + min-width: 2px; +} + +.phase-timing-legend { + display: flex; + flex-wrap: wrap; + gap: 14px; +} + +.phase-timing-entry { + display: inline-flex; + align-items: center; + gap: 6px; + color: #8ba6bc; + font-size: 11px; +} + +.phase-timing-entry strong { + color: #cfe3f2; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-weight: 600; +} + +.phase-timing-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.overview-sections { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.overview-section { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.overview-section-title { + display: flex; + align-items: baseline; + gap: 8px; + margin: 0; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #5a7a8f; +} + +.overview-section-bytes { + font-size: 11px; + font-weight: 400; + text-transform: none; + letter-spacing: 0; + color: #3d5a6a; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; +} + +.overview-key-headers { + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid rgba(145, 183, 210, 0.12); + border-radius: 10px; + overflow: hidden; +} + +.overview-header-row { + display: grid; + grid-template-columns: minmax(80px, 0.4fr) minmax(0, 1fr) 22px; + gap: 8px; + align-items: center; + padding: 5px 10px; + font-size: 12px; + border-bottom: 1px solid rgba(145, 183, 210, 0.07); + background: rgba(0, 0, 0, 0.15); +} + +.overview-header-row:last-child { + border-bottom: none; +} + +.overview-header-row code { + color: #7eb8cc; + font-size: 11px; +} + +.overview-header-row span { + color: #afc4d6; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 11px; +} + +.overview-body-preview { + font-size: 12px; + max-height: 240px; +} + +.overview-form-fields { + font-size: 12px; +} + +.overview-truncated { + color: #5a7a8f; + font-style: italic; +} + +.overview-binary { + margin: 0; + color: #5a7a8f; + font-size: 12px; + font-style: italic; +} + +.overview-empty { + margin: 0; + color: #5a7a8f; + font-size: 12px; + font-style: italic; +} + +.overview-more { + margin: 4px 0 0; + color: #5a7a8f; + font-size: 11px; + font-style: italic; +} + +@container (max-width: 560px) { + .overview-sections { + grid-template-columns: 1fr; + } +} + .diagnostic-card { margin-top: 18px; border: 1px solid rgba(240, 96, 96, 0.26); @@ -514,7 +1436,6 @@ code { } .body-box { - max-height: 360px; margin: 0; overflow: auto; border: 1px solid rgba(145, 183, 210, 0.14); @@ -535,10 +1456,132 @@ code { font-size: 13px; } +.sse-viewer { + display: grid; + gap: 10px; +} + +.sse-summary { + display: flex; + flex-wrap: wrap; + gap: 8px; + color: #8ba6bc; + font-size: 12px; +} + +.sse-summary span, +.sse-chip { + border-radius: 999px; + border: 1px solid rgba(145, 183, 210, 0.18); + background: rgba(255, 255, 255, 0.04); + padding: 3px 8px; +} + +.sse-events { + display: grid; + gap: 10px; +} + +.sse-event { + border: 1px solid rgba(93, 232, 160, 0.16); + border-radius: 14px; + padding: 10px; + background: linear-gradient(135deg, rgba(93, 232, 160, 0.06), rgba(0, 0, 0, 0.16)); +} + +.sse-event.incomplete { + border-color: rgba(240, 192, 96, 0.3); +} + +.sse-event-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.sse-event-index { + color: #5de8a0; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 12px; + font-weight: 900; +} + +.sse-chip { + color: #afc4d6; + font-size: 11px; + font-weight: 800; +} + +.sse-chip.warning { + color: #f0c060; + border-color: rgba(240, 192, 96, 0.28); + background: rgba(240, 192, 96, 0.1); +} + +.sse-comments { + display: grid; + gap: 3px; + margin-bottom: 8px; + color: #5a7a8f; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 12px; +} + +.sse-data { + margin: 0; + overflow: auto; + color: #d9e6f2; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.form-fields { + display: grid; + gap: 4px; + border: 1px solid rgba(145, 183, 210, 0.14); + border-radius: 14px; + padding: 10px 12px; + background: rgba(0, 0, 0, 0.22); +} + +.form-field-row { + display: grid; + grid-template-columns: minmax(100px, 0.38fr) minmax(0, 1fr) 22px; + gap: 12px; + align-items: center; + padding: 6px 4px; + font-size: 13px; + border-bottom: 1px solid rgba(145, 183, 210, 0.07); +} + +.form-field-row:last-child { + border-bottom: none; +} + +.form-field-row span { + min-width: 0; + overflow-wrap: anywhere; + color: #afc4d6; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 12px; +} + +.empty-value { + color: #5a7a8f; + font-style: italic; + font-family: inherit; +} + .header-row { display: grid; - grid-template-columns: minmax(120px, 0.45fr) minmax(0, 1fr); + grid-template-columns: minmax(120px, 0.45fr) minmax(0, 1fr) 22px; gap: 10px; + align-items: center; font-size: 13px; } @@ -551,7 +1594,7 @@ code { .empty-state { display: grid; place-content: center; - min-height: 360px; + flex: 1; color: #8ba6bc; text-align: center; } @@ -570,25 +1613,25 @@ code { padding: 20px; } - .hero, - .status-card { + .app-header { + flex-wrap: wrap; align-items: stretch; } - .status-card { - grid-template-columns: 1fr; + .status-pills { + flex-wrap: wrap; } - .status-item { - align-items: flex-start; + .traffic-grid { flex-direction: column; } - .grid { - grid-template-columns: 1fr; + .exchange-list { + width: 100% !important; + padding-right: 0; } - .traffic-grid { - grid-template-columns: 1fr; + .resize-handle { + display: none; } } diff --git a/crates/capture/src/lib.rs b/crates/capture/src/lib.rs index 990a0a4..4ae55b8 100644 --- a/crates/capture/src/lib.rs +++ b/crates/capture/src/lib.rs @@ -1,6 +1,34 @@ use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; use uuid::Uuid; +const MAX_STREAM_BODY_BYTES: usize = 256 * 1024; +const STREAM_PERSIST_INTERVAL: Duration = Duration::from_secs(1); +const STREAM_PERSIST_BODY_DELTA: usize = 64 * 1024; + +/// A rule that stops matching exchanges from being captured/recorded (or, in +/// the desktop UI, just hides them from view). `host` may contain `*` +/// wildcards (e.g. `*.google.com`); `method` is `None` for "any method" or a +/// specific verb like `"POST"`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IgnoreRule { + pub host: String, + pub method: Option, +} + +impl IgnoreRule { + fn matches(&self, host: &str, method: &str) -> bool { + glob_match(&self.host, host) + && self + .method + .as_deref() + .map_or(true, |m| m.eq_ignore_ascii_case(method)) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct HeaderPair { @@ -12,6 +40,8 @@ pub struct HeaderPair { #[serde(rename_all = "camelCase")] pub struct CapturedExchange { pub id: Uuid, + #[serde(default)] + pub pinned: bool, pub process_pid: Option, pub process_name: Option, pub method: String, @@ -22,5 +52,350 @@ pub struct CapturedExchange { pub request_body: Option>, pub response_body: Option>, pub duration_ms: Option, + /// Phase breakdown of `duration_ms`: time spent sending the request, + /// waiting for the server's first response byte (time to first byte), + /// and receiving the response. `None` when the corresponding phase never + /// completed (e.g. the flow errored out mid-request). + pub send_duration_ms: Option, + pub wait_duration_ms: Option, + pub receive_duration_ms: Option, pub error: Option, + pub timestamp_ms: u64, + pub streaming: bool, + pub response_body_truncated: bool, +} + +// ── In-memory capture store ───────────────────────────────────────────────── +// +// Shared state fed by whichever `mitm_engine` capture session is currently +// running, and drained by the Tauri commands that serve the UI. This used to +// live in `crates/proxy` alongside a hand-rolled MITM engine; that engine is +// gone (mitmproxy is the engine now) but the store itself is still exactly +// what the UI needs, so it moved here instead of being deleted. + +static CAPTURES: OnceLock>> = OnceLock::new(); +static CAPTURE_SINK: OnceLock> = OnceLock::new(); +static IGNORE_RULES: OnceLock>> = OnceLock::new(); +static STREAM_PERSIST_STATE: OnceLock>> = OnceLock::new(); + +#[derive(Debug, Clone)] +struct StreamPersistState { + last_persisted_at: Instant, + last_persisted_len: usize, +} + +fn captures() -> &'static Mutex> { + CAPTURES.get_or_init(|| Mutex::new(Vec::new())) +} + +fn ignore_rules() -> &'static Mutex> { + IGNORE_RULES.get_or_init(|| Mutex::new(Vec::new())) +} + +fn stream_persist_state() -> &'static Mutex> { + STREAM_PERSIST_STATE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Replaces the full ignore-rule list, e.g. when reloading it from storage at +/// startup or after any add/remove (the caller is expected to persist first, +/// then hand back the fresh full list — same pattern as fault rules). +pub fn set_ignored_hosts(rules: Vec) { + let mut set = ignore_rules().lock().expect("ignore rules poisoned"); + *set = rules + .into_iter() + .map(|rule| IgnoreRule { + host: rule.host.to_ascii_lowercase(), + method: rule + .method + .map(|m| m.to_ascii_uppercase()) + .filter(|m| !m.is_empty()), + }) + .collect(); +} + +/// Extracts the `host[:port]` authority from a URL without pulling in a full +/// URL-parsing dependency — good enough to match against the ignored-host +/// list, which is populated from the same shape of string (see `hostOf` in +/// the desktop UI). +fn host_of(url: &str) -> Option { + let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url); + let authority_end = after_scheme + .find(['/', '?', '#']) + .unwrap_or(after_scheme.len()); + let authority = &after_scheme[..authority_end]; + let host_port = authority + .rsplit_once('@') + .map(|(_, h)| h) + .unwrap_or(authority); + if host_port.is_empty() { + None + } else { + Some(host_port.to_ascii_lowercase()) + } +} + +fn is_ignored(url: &str, method: &str) -> bool { + match host_of(url) { + Some(host) => ignore_rules() + .lock() + .expect("ignore rules poisoned") + .iter() + .any(|rule| rule.matches(&host, method)), + None => false, + } +} + +/// Matches `text` against `pattern`, where `*` in `pattern` matches any run of +/// characters (e.g. `*.google.com` matches `mail.google.com`). Both sides are +/// expected to already be lowercase. Classic two-pointer wildcard matching — +/// no need for a regex dependency for a single wildcard character. +fn glob_match(pattern: &str, text: &str) -> bool { + let p: Vec = pattern.chars().collect(); + let t: Vec = text.chars().collect(); + let (mut pi, mut ti) = (0usize, 0usize); + let mut star: Option = None; + let mut matched = 0usize; + + while ti < t.len() { + if pi < p.len() && (p[pi] == '*' || p[pi] == t[ti]) { + if p[pi] == '*' { + star = Some(pi); + matched = ti; + pi += 1; + } else { + pi += 1; + ti += 1; + } + } else if let Some(star_pi) = star { + pi = star_pi + 1; + matched += 1; + ti = matched; + } else { + return false; + } + } + + while pi < p.len() && p[pi] == '*' { + pi += 1; + } + pi == p.len() +} + +#[cfg(test)] +mod glob_tests { + use super::{glob_match, IgnoreRule}; + + #[test] + fn matches_leading_wildcard_subdomains() { + assert!(glob_match("*.google.com", "mail.google.com")); + assert!(glob_match("*.google.com", "a.b.google.com")); + assert!(!glob_match("*.google.com", "google.com")); + assert!(!glob_match("*.google.com", "evilgoogle.com")); + } + + #[test] + fn matches_exact_and_trailing_wildcard() { + assert!(glob_match("api.example.com", "api.example.com")); + assert!(!glob_match("api.example.com", "api.example.com:8080")); + assert!(glob_match("api.example.com*", "api.example.com:8080")); + assert!(glob_match("*", "anything.at.all")); + } + + #[test] + fn rule_with_no_method_matches_any_verb() { + let rule = IgnoreRule { + host: "*.google.com".into(), + method: None, + }; + assert!(rule.matches("mail.google.com", "GET")); + assert!(rule.matches("mail.google.com", "POST")); + assert!(!rule.matches("mail.yahoo.com", "GET")); + } + + #[test] + fn rule_with_method_only_matches_that_verb() { + let rule = IgnoreRule { + host: "*.google.com".into(), + method: Some("POST".into()), + }; + assert!(rule.matches("mail.google.com", "POST")); + assert!(rule.matches("mail.google.com", "post")); + assert!(!rule.matches("mail.google.com", "GET")); + } +} + +/// Registers the sink that persists every captured exchange (SQLite today). +pub fn set_capture_sink(sink: impl Fn(&CapturedExchange) + Send + Sync + 'static) { + let _ = CAPTURE_SINK.set(Box::new(sink)); +} + +/// Seeds the in-memory store from persisted storage at startup. +pub fn preload_exchanges(exchanges: Vec) { + let mut store = captures().lock().expect("capture store poisoned"); + store.extend(exchanges); +} + +pub fn captured_exchanges() -> Vec { + captures().lock().expect("capture store poisoned").clone() +} + +pub fn captured_exchange_summaries() -> Vec { + captures() + .lock() + .expect("capture store poisoned") + .iter() + .map(|exchange| CapturedExchange { + id: exchange.id, + pinned: exchange.pinned, + process_pid: exchange.process_pid, + process_name: exchange.process_name.clone(), + method: exchange.method.clone(), + url: exchange.url.clone(), + status: exchange.status, + request_headers: exchange.request_headers.clone(), + response_headers: exchange.response_headers.clone(), + request_body: None, + response_body: None, + duration_ms: exchange.duration_ms, + send_duration_ms: exchange.send_duration_ms, + wait_duration_ms: exchange.wait_duration_ms, + receive_duration_ms: exchange.receive_duration_ms, + error: exchange.error.clone(), + timestamp_ms: exchange.timestamp_ms, + streaming: exchange.streaming, + response_body_truncated: exchange.response_body_truncated, + }) + .collect() +} + +pub fn captured_exchange(id: Uuid) -> Option { + captures() + .lock() + .expect("capture store poisoned") + .iter() + .find(|exchange| exchange.id == id) + .cloned() +} + +pub fn clear_captures() { + captures() + .lock() + .expect("capture store poisoned") + .retain(|exchange| exchange.pinned); + stream_persist_state() + .lock() + .expect("stream persist state poisoned") + .clear(); +} + +/// Records an exchange captured by a `mitm_engine` session through the shared +/// capture sink/store pipeline. Exchanges matching an ignore rule (host +/// pattern + optional method) are dropped entirely — neither persisted nor +/// kept in memory — since "ignore" means "stop recording this going forward". +pub fn push_capture(mut exchange: CapturedExchange) { + if is_ignored(&exchange.url, &exchange.method) { + return; + } + + enforce_stream_body_limit(&mut exchange); + + let should_persist = should_persist(&exchange); + if should_persist { + if let Some(sink) = CAPTURE_SINK.get() { + sink(&exchange); + } + } + + let mut captures = captures().lock().expect("capture store poisoned"); + if let Some(existing) = captures + .iter_mut() + .find(|existing| existing.id == exchange.id) + { + exchange.pinned = existing.pinned; + *existing = exchange; + } else { + captures.push(exchange); + } + if captures.len() > 500 { + if let Some(index) = captures.iter().position(|exchange| !exchange.pinned) { + captures.remove(index); + } + } +} + +pub fn set_capture_pinned(id: Uuid, pinned: bool) -> Option { + captures() + .lock() + .expect("capture store poisoned") + .iter_mut() + .find(|exchange| exchange.id == id) + .map(|exchange| { + exchange.pinned = pinned; + exchange.clone() + }) +} + +pub fn delete_capture(id: Uuid) -> bool { + let mut captures = captures().lock().expect("capture store poisoned"); + let original_len = captures.len(); + captures.retain(|exchange| exchange.id != id); + stream_persist_state() + .lock() + .expect("stream persist state poisoned") + .remove(&id); + captures.len() != original_len +} + +fn enforce_stream_body_limit(exchange: &mut CapturedExchange) { + if !exchange.streaming && !exchange.response_body_truncated { + return; + } + let Some(body) = exchange.response_body.as_mut() else { + return; + }; + if body.len() <= MAX_STREAM_BODY_BYTES { + return; + } + let overflow = body.len() - MAX_STREAM_BODY_BYTES; + body.drain(..overflow); + exchange.response_body_truncated = true; +} + +fn should_persist(exchange: &CapturedExchange) -> bool { + if !exchange.streaming { + stream_persist_state() + .lock() + .expect("stream persist state poisoned") + .remove(&exchange.id); + return true; + } + + let body_len = exchange.response_body.as_ref().map_or(0, Vec::len); + let mut state = stream_persist_state() + .lock() + .expect("stream persist state poisoned"); + match state.get_mut(&exchange.id) { + None => { + state.insert( + exchange.id, + StreamPersistState { + last_persisted_at: Instant::now(), + last_persisted_len: body_len, + }, + ); + true + } + Some(existing) => { + let elapsed = existing.last_persisted_at.elapsed() >= STREAM_PERSIST_INTERVAL; + let grew_enough = + body_len.saturating_sub(existing.last_persisted_len) >= STREAM_PERSIST_BODY_DELTA; + if elapsed || grew_enough { + existing.last_persisted_at = Instant::now(); + existing.last_persisted_len = body_len; + true + } else { + false + } + } + } } diff --git a/crates/certs/Cargo.toml b/crates/certs/Cargo.toml deleted file mode 100644 index 8a9191a..0000000 --- a/crates/certs/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "certs" -version = "0.1.0" -edition.workspace = true -license.workspace = true - -[dependencies] -anyhow.workspace = true -dirs = "6.0" -rcgen = { version = "=0.12.1", features = ["x509-parser"] } -serde.workspace = true -sha2.workspace = true diff --git a/crates/certs/src/lib.rs b/crates/certs/src/lib.rs deleted file mode 100644 index 7dfb61a..0000000 --- a/crates/certs/src/lib.rs +++ /dev/null @@ -1,289 +0,0 @@ -use anyhow::{Context, Result}; -use rcgen::{ - BasicConstraints, Certificate, CertificateParams, DistinguishedName, DnType, IsCa, KeyPair, -}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::fs; -use std::path::PathBuf; - -const APP_DIR_NAME: &str = "OpenIntercept"; -const CA_CERT_FILE: &str = "openintercept-ca.pem"; -const CA_KEY_FILE: &str = "openintercept-ca-key.pem"; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CaStatus { - pub available: bool, - pub generated: bool, - pub cert_path: String, - pub key_path: String, - pub fingerprint_sha256: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HostCertificateStatus { - pub hostname: String, - pub generated: bool, - pub cert_path: String, - pub key_path: String, - pub fingerprint_sha256: String, -} - -#[derive(Debug, Clone)] -pub struct HostCertificatePem { - pub cert_pem: String, - pub key_pem: String, -} - -pub fn app_data_dir() -> Result { - let data_dir = dirs::data_local_dir().context("failed to resolve local data directory")?; - Ok(data_dir.join(APP_DIR_NAME)) -} - -pub fn ensure_ca() -> Result { - let cert_dir = cert_dir()?; - fs::create_dir_all(&cert_dir).with_context(|| { - format!( - "failed to create certificate directory {}", - cert_dir.display() - ) - })?; - - let cert_path = cert_dir.join(CA_CERT_FILE); - let key_path = cert_dir.join(CA_KEY_FILE); - let generated = if cert_path.exists() && key_path.exists() { - false - } else { - let ca = generate_ca()?; - fs::write(&cert_path, ca.cert_pem) - .with_context(|| format!("failed to write CA certificate {}", cert_path.display()))?; - fs::write(&key_path, ca.key_pem) - .with_context(|| format!("failed to write CA key {}", key_path.display()))?; - true - }; - - let cert_pem = fs::read_to_string(&cert_path) - .with_context(|| format!("failed to read CA certificate {}", cert_path.display()))?; - - Ok(CaStatus { - available: true, - generated, - cert_path: cert_path.display().to_string(), - key_path: key_path.display().to_string(), - fingerprint_sha256: fingerprint_sha256(cert_pem.as_bytes()), - }) -} - -pub fn ca_status() -> Result { - let cert_dir = cert_dir()?; - let cert_path = cert_dir.join(CA_CERT_FILE); - let key_path = cert_dir.join(CA_KEY_FILE); - - if !cert_path.exists() || !key_path.exists() { - return Ok(CaStatus { - available: false, - generated: false, - cert_path: cert_path.display().to_string(), - key_path: key_path.display().to_string(), - fingerprint_sha256: String::new(), - }); - } - - let cert_pem = fs::read_to_string(&cert_path) - .with_context(|| format!("failed to read CA certificate {}", cert_path.display()))?; - - Ok(CaStatus { - available: true, - generated: false, - cert_path: cert_path.display().to_string(), - key_path: key_path.display().to_string(), - fingerprint_sha256: fingerprint_sha256(cert_pem.as_bytes()), - }) -} - -pub fn ensure_host_certificate(hostname: &str) -> Result { - let hostname = normalize_hostname(hostname)?; - ensure_ca()?; - - let cert_dir = cert_dir()?.join("hosts"); - fs::create_dir_all(&cert_dir).with_context(|| { - format!( - "failed to create host certificate directory {}", - cert_dir.display() - ) - })?; - - let safe_hostname = safe_filename(&hostname); - let cert_path = cert_dir.join(format!("{safe_hostname}.pem")); - let key_path = cert_dir.join(format!("{safe_hostname}-key.pem")); - let generated = if cert_path.exists() && key_path.exists() { - false - } else { - let certificate = generate_host_certificate(&hostname)?; - fs::write(&cert_path, certificate.cert_pem) - .with_context(|| format!("failed to write host certificate {}", cert_path.display()))?; - fs::write(&key_path, certificate.key_pem) - .with_context(|| format!("failed to write host key {}", key_path.display()))?; - true - }; - - let cert_pem = fs::read_to_string(&cert_path) - .with_context(|| format!("failed to read host certificate {}", cert_path.display()))?; - - Ok(HostCertificateStatus { - hostname, - generated, - cert_path: cert_path.display().to_string(), - key_path: key_path.display().to_string(), - fingerprint_sha256: fingerprint_sha256(cert_pem.as_bytes()), - }) -} - -pub fn load_host_certificate(hostname: &str) -> Result { - let status = ensure_host_certificate(hostname)?; - let cert_pem = fs::read_to_string(&status.cert_path) - .with_context(|| format!("failed to read host certificate {}", status.cert_path))?; - let key_pem = fs::read_to_string(&status.key_path) - .with_context(|| format!("failed to read host key {}", status.key_path))?; - - Ok(HostCertificatePem { cert_pem, key_pem }) -} - -struct GeneratedCa { - cert_pem: String, - key_pem: String, -} - -struct GeneratedHostCertificate { - cert_pem: String, - key_pem: String, -} - -fn generate_ca() -> Result { - let mut params = CertificateParams::default(); - let mut distinguished_name = DistinguishedName::new(); - distinguished_name.push(DnType::CommonName, "OpenIntercept Local CA"); - distinguished_name.push(DnType::OrganizationName, "OpenIntercept"); - params.distinguished_name = distinguished_name; - params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); - params.key_usages = vec![ - rcgen::KeyUsagePurpose::KeyCertSign, - rcgen::KeyUsagePurpose::DigitalSignature, - rcgen::KeyUsagePurpose::CrlSign, - ]; - - let certificate = - Certificate::from_params(params).context("failed to generate OpenIntercept CA")?; - - Ok(GeneratedCa { - cert_pem: certificate - .serialize_pem() - .context("failed to serialize CA certificate")?, - key_pem: certificate.serialize_private_key_pem(), - }) -} - -fn generate_host_certificate(hostname: &str) -> Result { - let ca = load_ca_certificate()?; - let mut params = CertificateParams::new(vec![hostname.to_string()]); - let mut distinguished_name = DistinguishedName::new(); - distinguished_name.push(DnType::CommonName, hostname); - distinguished_name.push(DnType::OrganizationName, "OpenIntercept"); - params.distinguished_name = distinguished_name; - params.is_ca = IsCa::NoCa; - params.key_usages = vec![ - rcgen::KeyUsagePurpose::DigitalSignature, - rcgen::KeyUsagePurpose::KeyEncipherment, - ]; - - let certificate = Certificate::from_params(params) - .with_context(|| format!("failed to generate certificate for {hostname}"))?; - - Ok(GeneratedHostCertificate { - cert_pem: certificate - .serialize_pem_with_signer(&ca) - .with_context(|| format!("failed to sign certificate for {hostname}"))?, - key_pem: certificate.serialize_private_key_pem(), - }) -} - -fn load_ca_certificate() -> Result { - let cert_dir = cert_dir()?; - let cert_path = cert_dir.join(CA_CERT_FILE); - let key_path = cert_dir.join(CA_KEY_FILE); - let cert_pem = fs::read_to_string(&cert_path) - .with_context(|| format!("failed to read CA certificate {}", cert_path.display()))?; - let key_pem = fs::read_to_string(&key_path) - .with_context(|| format!("failed to read CA key {}", key_path.display()))?; - let key_pair = KeyPair::from_pem(&key_pem).context("failed to parse CA key")?; - let params = CertificateParams::from_ca_cert_pem(&cert_pem, key_pair) - .context("failed to parse CA certificate")?; - - Certificate::from_params(params).context("failed to load CA certificate") -} - -fn cert_dir() -> Result { - Ok(app_data_dir()?.join("certs")) -} - -fn normalize_hostname(hostname: &str) -> Result { - let hostname = hostname.trim().trim_end_matches('.').to_ascii_lowercase(); - anyhow::ensure!(!hostname.is_empty(), "hostname cannot be empty"); - anyhow::ensure!(!hostname.contains('/'), "hostname cannot contain slashes"); - anyhow::ensure!(!hostname.contains(':'), "hostname must not include a port"); - - Ok(hostname) -} - -fn safe_filename(hostname: &str) -> String { - hostname - .chars() - .map(|character| match character { - 'a'..='z' | '0'..='9' | '.' | '-' | '_' => character, - _ => '_', - }) - .collect() -} - -fn fingerprint_sha256(bytes: &[u8]) -> String { - let digest = Sha256::digest(bytes); - digest - .iter() - .map(|byte| format!("{byte:02X}")) - .collect::>() - .join(":") -} - -#[cfg(test)] -mod tests { - use super::{ensure_host_certificate, normalize_hostname, safe_filename}; - - #[test] - fn normalizes_hostname() { - assert_eq!(normalize_hostname(" Example.COM. ").unwrap(), "example.com"); - } - - #[test] - fn rejects_hostname_with_port() { - assert!(normalize_hostname("example.com:443").is_err()); - } - - #[test] - fn creates_safe_filename() { - assert_eq!(safe_filename("api.example.com"), "api.example.com"); - assert_eq!(safe_filename("*.example.com"), "_.example.com"); - } - - #[test] - fn generates_and_reuses_host_certificate() { - let first = ensure_host_certificate("example.com").unwrap(); - let second = ensure_host_certificate("example.com").unwrap(); - - assert_eq!(first.hostname, "example.com"); - assert_eq!(first.cert_path, second.cert_path); - assert_eq!(first.key_path, second.key_path); - assert_eq!(first.fingerprint_sha256, second.fingerprint_sha256); - } -} diff --git a/crates/java_processes/Cargo.toml b/crates/fault_rules/Cargo.toml similarity index 62% rename from crates/java_processes/Cargo.toml rename to crates/fault_rules/Cargo.toml index 1c2ff81..6fe70e9 100644 --- a/crates/java_processes/Cargo.toml +++ b/crates/fault_rules/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "java_processes" +name = "fault_rules" version = "0.1.0" edition.workspace = true license.workspace = true [dependencies] -anyhow.workspace = true serde.workspace = true -sysinfo = "0.30" +serde_json.workspace = true +uuid.workspace = true diff --git a/crates/fault_rules/src/lib.rs b/crates/fault_rules/src/lib.rs new file mode 100644 index 0000000..15bf8fd --- /dev/null +++ b/crates/fault_rules/src/lib.rs @@ -0,0 +1,119 @@ +//! Fault injection rule model shared between `mitm_engine` (which pushes rules to +//! `bridge_addon.py` over the bridge control channel) and `storage` (which persists +//! them). Matching and action application both happen in `bridge_addon.py`, since +//! that is the only place that sees the decrypted mitmproxy flow; this crate only +//! defines the wire/storage shape so both ends agree on it. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Which side of the exchange a rule's action applies to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuleTarget { + Request, + Response, +} + +/// Match predicate: every present field must match for the rule to apply. +/// `None` fields are wildcards. `path_pattern` is a substring match today; +/// swapping in a regex is a compatible follow-up (same wire field). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RuleMatch { + pub host: Option, + pub method: Option, + pub path_pattern: Option, + pub header_equals: Option<(String, String)>, +} + +/// What to do to a matched request or response. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum FaultAction { + /// Sleep before letting the request/response continue. + Delay { ms: u64 }, + /// Replace the response with a synthetic one; only valid with + /// `RuleTarget::Request` (it short-circuits the real server call) or + /// `RuleTarget::Response` (it replaces the real response). + ForceStatus { + status: u16, + body: Option, + headers: Option>, + }, + /// Kill the connection instead of completing it. + Abort, + /// Cap the transfer rate of the body, in bytes per second. + Throttle { + #[serde(rename = "bytesPerSecond")] + bytes_per_second: u64, + }, + /// Let a streamed response run briefly, then kill the connection. Useful + /// for testing SSE/EventSource reconnection behavior. + DisconnectStream { + #[serde(rename = "afterMs")] + after_ms: u64, + once: bool, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FaultRule { + pub id: Uuid, + pub name: String, + pub enabled: bool, + #[serde(default = "default_probability")] + pub probability: f64, + pub target: RuleTarget, + #[serde(rename = "match")] + pub rule_match: RuleMatch, + pub action: FaultAction, +} + +fn default_probability() -> f64 { + 1.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_through_json() { + let rule = FaultRule { + id: Uuid::new_v4(), + name: "slow login".into(), + enabled: true, + probability: 1.0, + target: RuleTarget::Response, + rule_match: RuleMatch { + host: Some("api.example.com".into()), + method: Some("POST".into()), + path_pattern: Some("/login".into()), + header_equals: None, + }, + action: FaultAction::Delay { ms: 2000 }, + }; + + let json = serde_json::to_string(&rule).expect("serialize"); + let back: FaultRule = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back.name, "slow login"); + assert!(matches!(back.action, FaultAction::Delay { ms: 2000 })); + } + + #[test] + fn missing_probability_defaults_to_always_apply() { + let json = r#"{ + "id":"00000000-0000-4000-8000-000000000000", + "name":"legacy rule", + "enabled":true, + "target":"response", + "match":{}, + "action":{"kind":"abort"} + }"#; + + let rule: FaultRule = serde_json::from_str(json).expect("deserialize legacy rule"); + assert_eq!(rule.probability, 1.0); + } +} diff --git a/crates/java_processes/src/lib.rs b/crates/java_processes/src/lib.rs deleted file mode 100644 index bb26aae..0000000 --- a/crates/java_processes/src/lib.rs +++ /dev/null @@ -1,120 +0,0 @@ -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; -use std::process::Command; -use sysinfo::System; - -#[cfg(windows)] -const BUILD_SCRIPT: &str = "scripts/build-java.ps1"; -#[cfg(not(windows))] -const BUILD_SCRIPT: &str = "scripts/build-java.sh"; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct JavaProcess { - pub pid: u32, - pub name: String, - pub command: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AttachResult { - pub pid: u32, - pub success: bool, - pub stdout: String, - pub stderr: String, -} - -pub fn list_java_processes() -> Result> { - let mut system = System::new_all(); - system.refresh_processes(); - - let mut processes = system - .processes() - .iter() - .filter_map(|(pid, process)| { - let name = process.name().to_string(); - let command = process.cmd().join(" "); - let searchable = format!("{name} {command}").to_ascii_lowercase(); - - if !is_java_process(&searchable) { - return None; - } - - Some(JavaProcess { - pid: pid.as_u32(), - name, - command, - }) - }) - .collect::>(); - - processes.sort_by(|left, right| left.name.cmp(&right.name).then(left.pid.cmp(&right.pid))); - - Ok(processes) -} - -fn is_java_process(searchable: &str) -> bool { - searchable.contains("java") - || searchable.contains("java.exe") - || searchable.contains("javaw") - || searchable.contains("javaw.exe") -} - -pub fn attach_java_process( - pid: u32, - helper_jar: impl AsRef, - agent_jar: impl AsRef, - proxy_host: &str, - proxy_port: u16, - ca_cert_path: Option<&Path>, -) -> Result { - let helper_jar = helper_jar.as_ref(); - let agent_jar = agent_jar.as_ref(); - - anyhow::ensure!( - helper_jar.exists(), - "attach helper jar not found at {}. Run {} first.", - helper_jar.display(), - BUILD_SCRIPT - ); - anyhow::ensure!( - agent_jar.exists(), - "agent jar not found at {}. Run {} first.", - agent_jar.display(), - BUILD_SCRIPT - ); - - let mut agent_args = format!("proxyHost={proxy_host},proxyPort={proxy_port}"); - if let Some(ca_path) = ca_cert_path { - agent_args.push_str(&format!(",caCertPath={}", ca_path.display())); - } - - let output = Command::new(java_executable()) - .arg("--add-modules") - .arg("jdk.attach") - .arg("-jar") - .arg(helper_jar) - .arg(pid.to_string()) - .arg(agent_jar) - .arg(agent_args) - .output() - .context("failed to execute Java attach helper")?; - - Ok(AttachResult { - pid, - success: output.status.success(), - stdout: String::from_utf8_lossy(&output.stdout).to_string(), - stderr: String::from_utf8_lossy(&output.stderr).to_string(), - }) -} - -fn java_executable() -> PathBuf { - if let Ok(java_home) = std::env::var("JAVA_HOME") { - let binary = if cfg!(windows) { "java.exe" } else { "java" }; - return PathBuf::from(java_home).join("bin").join(binary); - } - - PathBuf::from("java") -} diff --git a/crates/proxy/Cargo.toml b/crates/mitm_engine/Cargo.toml similarity index 52% rename from crates/proxy/Cargo.toml rename to crates/mitm_engine/Cargo.toml index 289c21a..5198f94 100644 --- a/crates/proxy/Cargo.toml +++ b/crates/mitm_engine/Cargo.toml @@ -1,20 +1,21 @@ [package] -name = "proxy" +name = "mitm_engine" version = "0.1.0" edition.workspace = true license.workspace = true [dependencies] anyhow.workspace = true -brotli = "7" +base64 = "0.22" capture = { path = "../capture" } -certs = { path = "../certs" } -flate2 = "1" -rustls = "0.23" -rustls-native-certs = "0.8" -rustls-pemfile = "2.2" +fault_rules = { path = "../fault_rules" } serde.workspace = true +serde_json.workspace = true +sha2.workspace = true tokio.workspace = true -tokio-rustls = "0.26" uuid.workspace = true -webpki-roots = "0.26" +which = "6" + +[[bin]] +name = "mitm_engine_spike" +path = "src/bin/spike.rs" diff --git a/crates/mitm_engine/src/bin/spike.rs b/crates/mitm_engine/src/bin/spike.rs new file mode 100644 index 0000000..8977de4 --- /dev/null +++ b/crates/mitm_engine/src/bin/spike.rs @@ -0,0 +1,45 @@ +//! Manual verification for ProcTap milestone 2: spawn mitmdump in `local:` mode, +//! receive flow JSON from bridge_addon.py over the local socket, and print each flow. +//! +//! Usage: `cargo run -p mitm_engine --bin mitm_engine_spike -- ` +//! Then generate traffic from that process (e.g. `curl.exe http://example.com`) and +//! confirm it is printed, while traffic from other processes is not. + +use anyhow::Result; +use mitm_engine::{CaptureTarget, MitmEngine, ProxyMode}; +use std::path::PathBuf; + +#[tokio::main] +async fn main() -> Result<()> { + let target_name = std::env::args() + .nth(1) + .unwrap_or_else(|| "curl.exe".to_string()); + let mode = ProxyMode::Local(CaptureTarget::by_name(target_name.clone())); + + let mitmdump_path = mitm_engine::find_mitmdump()?; + println!("using mitmdump at {}", mitmdump_path.display()); + + let confdir = std::env::temp_dir().join("proctap-mitm-spike"); + std::fs::create_dir_all(&confdir)?; + let addon_path: PathBuf = confdir.join("bridge_addon.py"); + + let engine = MitmEngine::spawn(&mitmdump_path, &addon_path, &confdir, &mode).await?; + + println!("capturing traffic from process '{target_name}' - Ctrl+C to stop"); + engine + .run(&mode, |exchange| { + println!( + "{} {} -> {:?} ({} ms){}", + exchange.method, + exchange.url, + exchange.status, + exchange.duration_ms.unwrap_or(0), + exchange + .error + .as_ref() + .map(|e| format!(" error={e}")) + .unwrap_or_default(), + ); + }) + .await +} diff --git a/crates/mitm_engine/src/bridge_addon.py b/crates/mitm_engine/src/bridge_addon.py new file mode 100644 index 0000000..5cfb49a --- /dev/null +++ b/crates/mitm_engine/src/bridge_addon.py @@ -0,0 +1,420 @@ +""" +ProcTap bridge addon for mitmproxy. + +Serializes completed HTTP flows to JSON lines and streams SSE response bodies as +incremental JSON events over a loopback TCP socket to the mitm_engine Rust process. +The socket port is passed via the PROCTAP_BRIDGE_PORT environment variable. + +The same socket is duplex: mitm_engine also pushes fault-injection rule updates back +to this addon as JSON lines (`{"rules": [...]}`), read on a background thread and +applied in the request()/response() hooks before a flow is serialized and sent out. + +This is mitmproxy's documented addon/hook extension mechanism (response/error hooks), +not the internal mitmweb REST/WS API, so it is safe to rely on across mitmproxy versions. +""" + +import asyncio +import base64 +import json +import os +import queue +import random +import socket +import threading +import time + +from mitmproxy import http + + +MAX_BODY_BYTES = 256 * 1024 + + +class ProcTapBridge: + def __init__(self): + self.sock = None + self.outbox = queue.Queue(maxsize=2000) + self.disconnect_stream_hits = set() + # Replaced wholesale (never mutated in place) so reading it from the + # asyncio event loop thread while the listener thread swaps it in is + # safe without a lock: CPython attribute assignment is atomic. + self.rules = [] + + def running(self): + # Called once mitmproxy's event loop is up; the earliest safe point to + # open the control connection and start listening for rule pushes. + self._connect() + if self.sock is not None: + threading.Thread(target=self._send_loop, daemon=True).start() + threading.Thread(target=self._listen_for_rules, daemon=True).start() + + def _connect(self): + if self.sock is not None: + return + port = int(os.environ["PROCTAP_BRIDGE_PORT"]) + self.sock = socket.create_connection(("127.0.0.1", port), timeout=5) + + def _listen_for_rules(self): + buf = b"" + while True: + try: + chunk = self.sock.recv(65536) + except OSError: + return + if not chunk: + return + buf += chunk + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + if not line.strip(): + continue + try: + message = json.loads(line) + self.rules = message.get("rules", []) + except json.JSONDecodeError as e: + print(f"proctap bridge: dropping malformed rule update: {e}", flush=True) + + def _send(self, payload: dict) -> None: + try: + self.outbox.put_nowait(payload) + except queue.Full: + if payload.get("type") == "chunk": + print("proctap bridge: dropping streamed chunk because bridge outbox is full", flush=True) + return + try: + self.outbox.put(payload, timeout=1) + except queue.Full: + print("proctap bridge: dropping non-chunk message because bridge outbox is full", flush=True) + + def _send_loop(self) -> None: + while True: + payload = self.outbox.get() + self._send_now(payload) + + def _send_now(self, payload: dict) -> None: + try: + self._connect() + line = json.dumps(payload).encode("utf-8") + b"\n" + self.sock.sendall(line) + except OSError as e: + print(f"proctap bridge: failed to send flow: {e}", flush=True) + self.sock = None + + async def request(self, flow): + for rule in self._matching_rules(flow, "request"): + if await self._apply_action(flow, rule): + break # flow.response was set (forced or errored out): stop here + + async def response(self, flow): + # mitmproxy's response.timestamp_end is fixed before this hook runs, so + # any delay/throttle injected here happens after the real timings are + # already captured. Measure it separately and fold it into the stored + # durations in _serialize, otherwise fault-injected delays on the + # response side are invisible in the persisted exchange timings. + start = time.monotonic() + for rule in self._matching_rules(flow, "response"): + await self._apply_action(flow, rule) + injected_response_delay_ms = int((time.monotonic() - start) * 1000) + if flow.metadata.get("proctap_streaming"): + print(f"proctap bridge: streaming SSE done for {flow.request.pretty_url}", flush=True) + self._send(self._serialize_stream_done(flow, injected_response_delay_ms)) + else: + self._send(self._serialize(flow, injected_response_delay_ms)) + + def responseheaders(self, flow): + if not _should_stream_response(flow): + return + + flow.metadata["proctap_streaming"] = True + flow.metadata["proctap_stream_seq"] = 0 + self._configure_stream_disconnect(flow) + print(f"proctap bridge: streaming SSE started for {flow.request.pretty_url}", flush=True) + self._send(self._serialize_stream_started(flow)) + flow.response.stream = self._stream_response(flow) + + def error(self, flow): + if flow.metadata.get("proctap_streaming"): + self._send(self._serialize_stream_done(flow)) + else: + self._send(self._serialize(flow)) + + def _stream_response(self, flow): + def handle_chunk(chunk: bytes) -> bytes: + if self._should_disconnect_stream_now(flow): + print(f"proctap bridge: disconnecting stream for {flow.request.pretty_url}", flush=True) + flow.kill() + return b"" + + if chunk: + flow.metadata["proctap_stream_seq"] = flow.metadata.get("proctap_stream_seq", 0) + 1 + self._send( + { + "type": "chunk", + "id": flow.id, + "seq": flow.metadata["proctap_stream_seq"], + "body_b64": _encode_body(chunk), + "timestamp_ms": int(time.time() * 1000), + } + ) + return chunk + + return handle_chunk + + def _configure_stream_disconnect(self, flow) -> None: + for rule in self._matching_rules(flow, "response"): + action = rule.get("action", {}) + if action.get("kind") != "disconnect_stream": + continue + + rule_id = rule.get("id") or rule.get("name") or json.dumps(rule, sort_keys=True) + if action.get("once", True) and rule_id in self.disconnect_stream_hits: + continue + + flow.metadata["proctap_disconnect_stream_rule_id"] = rule_id + flow.metadata["proctap_disconnect_stream_after_ms"] = max(action.get("afterMs", 0), 0) + flow.metadata["proctap_disconnect_stream_started_at"] = time.monotonic() + return + + def _should_disconnect_stream_now(self, flow) -> bool: + rule_id = flow.metadata.get("proctap_disconnect_stream_rule_id") + if rule_id is None: + return False + + started_at = flow.metadata.get("proctap_disconnect_stream_started_at") + after_ms = flow.metadata.get("proctap_disconnect_stream_after_ms", 0) + if started_at is None or (time.monotonic() - started_at) * 1000 < after_ms: + return False + + self.disconnect_stream_hits.add(rule_id) + flow.metadata.pop("proctap_disconnect_stream_rule_id", None) + return True + + def _matching_rules(self, flow, target: str): + return [ + rule + for rule in self.rules + if rule.get("enabled", True) + and rule.get("target") == target + and _rule_matches(rule.get("match", {}), flow.request) + and _rule_probability_passes(rule) + ] + + async def _apply_action(self, flow, rule: dict) -> bool: + """Applies one rule's action. Returns True if the flow's response was set + (forced status or abort), signaling callers not to keep evaluating rules + that assume the request is still going to the real server.""" + action = rule.get("action", {}) + kind = action.get("kind") + + if kind == "delay": + await asyncio.sleep(action.get("ms", 0) / 1000) + return False + + if kind == "force_status": + body = (action.get("body") or "").encode("utf-8") + headers = dict(action.get("headers") or []) + flow.response = http.Response.make(action.get("status", 500), body, headers) + return True + + if kind == "abort": + flow.kill() + return True + + if kind == "throttle": + if flow.metadata.get("proctap_streaming"): + return False + body = flow.response.raw_content if flow.response else None + rate = action.get("bytesPerSecond", 0) + if body and rate > 0: + await asyncio.sleep(len(body) / rate) + return False + + if kind == "disconnect_stream": + return False + + print(f"proctap bridge: ignoring fault rule with unknown action kind: {kind}", flush=True) + return False + + def _serialize(self, flow, injected_response_delay_ms: int = 0) -> dict: + request = flow.request + response = flow.response + + duration_ms = None + if ( + request is not None + and response is not None + and request.timestamp_start is not None + and response.timestamp_end is not None + ): + duration_ms = ( + int((response.timestamp_end - request.timestamp_start) * 1000) + + injected_response_delay_ms + ) + + # Phase breakdown of `duration_ms`, from mitmproxy's own per-flow + # timestamps: how long the request took to go out, how long the + # server took to respond (time to first byte), and how long the + # response took to come back. Any of these can be None if the + # corresponding phase never completed (e.g. the flow errored out + # mid-request). `injected_response_delay_ms` folds in any + # fault-rule delay/throttle applied in the response() hook, which + # runs after these timestamps are already fixed. + send_duration_ms = _phase_ms( + request.timestamp_start if request else None, + request.timestamp_end if request else None, + ) + wait_duration_ms = _phase_ms( + request.timestamp_end if request else None, + response.timestamp_start if response else None, + ) + receive_duration_ms = _phase_ms( + response.timestamp_start if response else None, + response.timestamp_end if response else None, + ) + if receive_duration_ms is not None and injected_response_delay_ms: + receive_duration_ms += injected_response_delay_ms + + return { + "id": flow.id, + "method": request.method if request else "", + "url": request.pretty_url if request else "", + "status": response.status_code if response else None, + "request_headers": list(request.headers.items(multi=True)) if request else [], + "response_headers": list(response.headers.items(multi=True)) if response else [], + "request_body_b64": _encode_body(_bounded_body(request.raw_content if request else None)), + "response_body_b64": _encode_body(_bounded_body(response.raw_content if response else None)), + "response_body_truncated": _body_was_bounded(response.raw_content if response else None), + "duration_ms": duration_ms, + "send_duration_ms": send_duration_ms, + "wait_duration_ms": wait_duration_ms, + "receive_duration_ms": receive_duration_ms, + "error": flow.error.msg if flow.error else None, + "timestamp_ms": int(time.time() * 1000), + } + + def _serialize_stream_started(self, flow) -> dict: + request = flow.request + response = flow.response + return { + "type": "started", + "id": flow.id, + "method": request.method if request else "", + "url": request.pretty_url if request else "", + "status": response.status_code if response else None, + "request_headers": list(request.headers.items(multi=True)) if request else [], + "response_headers": list(response.headers.items(multi=True)) if response else [], + "request_body_b64": _encode_body(_bounded_body(request.raw_content if request else None)), + "timestamp_ms": int(time.time() * 1000), + } + + def _serialize_stream_done(self, flow, injected_response_delay_ms: int = 0) -> dict: + request = flow.request + response = flow.response + + duration_ms = None + if ( + request is not None + and response is not None + and request.timestamp_start is not None + and response.timestamp_end is not None + ): + duration_ms = ( + int((response.timestamp_end - request.timestamp_start) * 1000) + + injected_response_delay_ms + ) + + send_duration_ms = _phase_ms( + request.timestamp_start if request else None, + request.timestamp_end if request else None, + ) + wait_duration_ms = _phase_ms( + request.timestamp_end if request else None, + response.timestamp_start if response else None, + ) + receive_duration_ms = _phase_ms( + response.timestamp_start if response else None, + response.timestamp_end if response else None, + ) + if receive_duration_ms is not None and injected_response_delay_ms: + receive_duration_ms += injected_response_delay_ms + + return { + "type": "done", + "id": flow.id, + "status": response.status_code if response else None, + "response_headers": list(response.headers.items(multi=True)) if response else [], + "duration_ms": duration_ms, + "send_duration_ms": send_duration_ms, + "wait_duration_ms": wait_duration_ms, + "receive_duration_ms": receive_duration_ms, + "error": flow.error.msg if flow.error else None, + "timestamp_ms": int(time.time() * 1000), + } + + +def _rule_matches(match: dict, request) -> bool: + host = match.get("host") + if host is not None and request.pretty_host != host: + return False + + method = match.get("method") + if method is not None and request.method.upper() != method.upper(): + return False + + path_pattern = match.get("pathPattern") + if path_pattern is not None and path_pattern not in request.path: + return False + + header_equals = match.get("headerEquals") + if header_equals is not None: + name, value = header_equals + if request.headers.get(name) != value: + return False + + return True + + +def _rule_probability_passes(rule: dict) -> bool: + probability = rule.get("probability", 1.0) + try: + probability = float(probability) + except (TypeError, ValueError): + probability = 1.0 + + if probability >= 1.0: + return True + if probability <= 0.0: + return False + return random.random() < probability + + +def _phase_ms(start: float | None, end: float | None) -> int | None: + if start is None or end is None: + return None + return int((end - start) * 1000) + + +def _should_stream_response(flow) -> bool: + response = flow.response + if response is None: + return False + content_type = response.headers.get("content-type", "").lower() + return "text/event-stream" in content_type + + +def _encode_body(body: bytes | None) -> str | None: + if not body: + return None + return base64.b64encode(body).decode("ascii") + + +def _bounded_body(body: bytes | None) -> bytes | None: + if body is None or len(body) <= MAX_BODY_BYTES: + return body + return body[:MAX_BODY_BYTES] + + +def _body_was_bounded(body: bytes | None) -> bool: + return body is not None and len(body) > MAX_BODY_BYTES + + +addons = [ProcTapBridge()] diff --git a/crates/mitm_engine/src/lib.rs b/crates/mitm_engine/src/lib.rs new file mode 100644 index 0000000..99cdcb6 --- /dev/null +++ b/crates/mitm_engine/src/lib.rs @@ -0,0 +1,647 @@ +//! Runs mitmproxy as an external engine subprocess in `local:` mode and +//! turns the flow JSON emitted by `bridge_addon.py` into [`CapturedExchange`] values. + +use anyhow::{Context, Result}; +use capture::{CapturedExchange, HeaderPair}; +use fault_rules::FaultRule; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::process::Stdio; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; +use tokio::process::{Child, Command}; +use tokio::sync::watch; +use uuid::Uuid; + +pub mod trust; + +/// Path to `bridge_addon.py`, resolved relative to this crate at compile time so the +/// spike binary and the eventual Tauri command both find it without extra setup. +pub const BRIDGE_ADDON_SOURCE: &str = include_str!("bridge_addon.py"); +const MAX_STREAM_BODY_BYTES: usize = 256 * 1024; + +/// A running mitmproxy engine capturing traffic for a single target process. +pub struct MitmEngine { + child: Child, + listener: TcpListener, + log_sink: Option, +} + +pub type EngineLogSink = Arc; + +/// What to pass to mitmdump's `--mode local:`, plus the metadata to +/// attach to every [`CapturedExchange`] produced during the session. +/// +/// `pid` is `Some` only when the caller resolved the target to one specific +/// running process (picked from the process list, used to disambiguate +/// multiple processes sharing a name) rather than a bare executable name. +/// mitmproxy's own flow objects never expose the originating PID: verified +/// against mitmproxy 11.1.3 / mitmproxy_rs 0.11.5 by probing a live capture — +/// the underlying `mitmproxy_rs::Stream` supports +/// `get_extra_info("pid"/"process_name")`, but mitmproxy's proxy/server.py +/// only ever reads `peername`/`sockname`/`transport_protocol` off it, so that +/// data never reaches `flow.client_conn` and no addon can recover it after +/// the fact. `local:` does accept a literal numeric PID (not just an +/// executable name) as its target, though, so scoping mitmdump itself to one +/// exact PID — and then stamping every exchange from that session with the +/// PID we already know we asked for — is the only reliable way to attribute +/// a whole capture session to one exact process instance. +#[derive(Debug, Clone)] +pub struct CaptureTarget { + /// Process name/spec typed in the picker. Used for `local:` when a + /// PID was not selected, so UI display names can differ from capture specs. + pub target_name: String, + pub display_name: String, + pub pid: Option, +} + +impl CaptureTarget { + /// By bare name only, e.g. typed freehand or matching a single running + /// process — mitmdump keeps redirecting future instances of this name. + pub fn by_name(display_name: impl Into) -> Self { + let display_name = display_name.into(); + Self { + target_name: display_name.clone(), + display_name, + pid: None, + } + } + + /// One exact running process, disambiguated by PID. + pub fn by_pid(display_name: impl Into, pid: u32) -> Self { + let display_name = display_name.into(); + Self { + target_name: display_name.clone(), + display_name, + pid: Some(pid), + } + } + + fn spec(&self) -> String { + match self.pid { + Some(pid) => pid.to_string(), + None => self.target_name.clone(), + } + } +} + +/// Sentinel `process_name` stamped on every [`CapturedExchange`] produced by +/// an explicit-proxy ([`ProxyMode::Regular`]) session, since there is no +/// process to attribute the traffic to at all — any client that was pointed +/// at the port sees its traffic captured, regardless of what process it is. +/// This is deliberately a display string, not `None`: the UI already treats +/// `process_name: None` as "don't show a process badge," which would make +/// explicit-proxy traffic look unattributed-by-omission rather than +/// attributed-to-nothing-by-design. Chosen to be visually distinct from any +/// real executable name (`sysinfo` process names never contain spaces this +/// way) so it can never collide with genuine picker-based attribution. +pub const EXTERNAL_CLIENT_LABEL: &str = "External client"; + +/// What kind of mitmdump session to run: per-process capture (today's +/// picker-driven flow, OS-level redirection, no client configuration needed) +/// or a standing explicit proxy on a fixed port (the pre-pivot workflow: the +/// client must be configured to use it, but it works for any client +/// regardless of whether it can be targeted by process name/PID). The two +/// are independent and can run concurrently — see `main.rs`'s two separate +/// session-state slots. +#[derive(Debug, Clone)] +pub enum ProxyMode { + /// `--mode local:` — OS-level per-process redirection. + Local(CaptureTarget), + /// `--mode local:,` — one OS-level redirector matching + /// multiple process specs. Mitmproxy does not expose the originating PID + /// per flow, so exchanges from this mode use a shared display label. + LocalGroup { spec: String, display_name: String }, + /// `--mode regular@` — a standing HTTP(S) proxy a client must be + /// explicitly configured to use. No process attribution is possible or + /// attempted for traffic captured this way. + Regular { port: u16 }, +} + +impl ProxyMode { + fn mode_arg(&self) -> String { + match self { + ProxyMode::Local(target) => format!("local:{}", target.spec()), + ProxyMode::LocalGroup { spec, .. } => format!("local:{spec}"), + ProxyMode::Regular { port } => format!("regular@{port}"), + } + } +} + +/// One completed flow as emitted by older/new non-streaming `bridge_addon.py`, +/// one JSON object per line. +#[derive(Debug, Deserialize)] +struct WireExchange { + id: Option, + method: String, + url: String, + status: Option, + request_headers: Vec<(String, String)>, + response_headers: Vec<(String, String)>, + request_body_b64: Option, + response_body_b64: Option, + #[serde(default)] + response_body_truncated: bool, + duration_ms: Option, + send_duration_ms: Option, + wait_duration_ms: Option, + receive_duration_ms: Option, + error: Option, + timestamp_ms: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum WireMessage { + Event(WireEvent), + Exchange(WireExchange), +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum WireEvent { + Started(WireStarted), + Chunk(WireChunk), + Done(WireDone), +} + +#[derive(Debug, Deserialize)] +struct WireStarted { + id: String, + method: String, + url: String, + status: Option, + request_headers: Vec<(String, String)>, + response_headers: Vec<(String, String)>, + request_body_b64: Option, + timestamp_ms: u64, +} + +#[derive(Debug, Deserialize)] +struct WireChunk { + id: String, + body_b64: Option, + timestamp_ms: u64, +} + +#[derive(Debug, Deserialize)] +struct WireDone { + id: String, + status: Option, + response_headers: Vec<(String, String)>, + duration_ms: Option, + send_duration_ms: Option, + wait_duration_ms: Option, + receive_duration_ms: Option, + error: Option, + timestamp_ms: u64, +} + +impl MitmEngine { + /// Binds the local bridge socket and spawns `mitmdump --mode local:` with + /// `bridge_addon.py` loaded, pointed at that socket via environment variables. + /// + /// `mitmdump_path` is the resolved path to the mitmdump executable (see + /// [`find_mitmdump`]). `addon_path` is a writable path to persist + /// [`BRIDGE_ADDON_SOURCE`] to, since mitmproxy loads scripts from disk. + pub async fn spawn( + mitmdump_path: &std::path::Path, + addon_path: &std::path::Path, + confdir: &std::path::Path, + mode: &ProxyMode, + ) -> Result { + Self::spawn_with_log_sink(mitmdump_path, addon_path, confdir, mode, None).await + } + + pub async fn spawn_with_log_sink( + mitmdump_path: &std::path::Path, + addon_path: &std::path::Path, + confdir: &std::path::Path, + mode: &ProxyMode, + log_sink: Option, + ) -> Result { + // Only `local:` mode redirects traffic at the OS level (WinDivert/cgroup), + // which is what needs root on Linux. `regular` mode is a plain listening + // socket, same privilege requirements as any other server on a normal port. + #[cfg(target_os = "linux")] + if matches!(mode, ProxyMode::Local(_) | ProxyMode::LocalGroup { .. }) { + ensure_linux_capture_privileges()?; + } + + tokio::fs::write(addon_path, BRIDGE_ADDON_SOURCE) + .await + .with_context(|| format!("failed to write bridge addon to {}", addon_path.display()))?; + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .context("failed to bind local bridge socket")?; + let bridge_port = listener.local_addr()?.port(); + eprintln!( + "mitm_engine: starting mitmdump mode={} bridge=127.0.0.1:{} addon={}", + mode.mode_arg(), + bridge_port, + addon_path.display() + ); + + let mut child = Command::new(mitmdump_path) + .arg("--mode") + .arg(mode.mode_arg()) + .arg("--scripts") + .arg(addon_path) + .arg("--set") + .arg(format!("confdir={}", confdir.display())) + .arg("--set") + .arg("ssl_insecure=true") + .env("PROCTAP_BRIDGE_PORT", bridge_port.to_string()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .context("failed to spawn mitmdump")?; + + if let Some(stdout) = child.stdout.take() { + tokio::spawn(forward_child_output( + "mitmdump stdout", + stdout, + log_sink.clone(), + )); + } + if let Some(stderr) = child.stderr.take() { + tokio::spawn(forward_child_output( + "mitmdump stderr", + stderr, + log_sink.clone(), + )); + } + + Ok(Self { + child, + listener, + log_sink, + }) + } + + /// Accepts the addon's single bridge connection and streams captured exchanges to + /// `on_exchange` until the connection closes or mitmdump exits. + pub async fn run( + self, + mode: &ProxyMode, + on_exchange: impl FnMut(CapturedExchange), + ) -> Result<()> { + let (_tx, rx) = watch::channel(Vec::new()); + self.run_with_fault_rules(mode, on_exchange, rx).await + } + + /// Like [`Self::run`], but also pushes `rules` to `bridge_addon.py` over the same + /// bridge socket (duplex: the addon still sends flow JSON one way, `mitm_engine` + /// now also sends fault-rule updates the other way) whenever the watched value + /// changes, starting with its initial value once the addon connects. + pub async fn run_with_fault_rules( + mut self, + mode: &ProxyMode, + mut on_exchange: impl FnMut(CapturedExchange), + mut rules: watch::Receiver>, + ) -> Result<()> { + let (stream, _) = self.listener.accept().await.context( + "bridge addon never connected back to mitm_engine (is mitmdump running with bridge_addon.py loaded?)", + )?; + let (read_half, mut write_half) = stream.into_split(); + let mut lines = BufReader::new(read_half).lines(); + let mut streaming_exchanges = HashMap::::new(); + + let initial_rules = rules.borrow().clone(); + send_fault_rules(&mut write_half, &initial_rules).await?; + let mut rules_channel_open = true; + + loop { + tokio::select! { + line = lines.next_line() => { + match line? { + Some(line) if !line.trim().is_empty() => { + match serde_json::from_str::(&line) { + Ok(WireMessage::Exchange(wire)) => on_exchange(to_captured_exchange(wire, mode)), + Ok(WireMessage::Event(event)) => { + if let Some(exchange) = apply_wire_event(event, mode, &mut streaming_exchanges) { + on_exchange(exchange); + } + } + Err(e) => self.log_engine("warn", format!("dropping malformed flow line: {e}")), + } + } + Some(_) => {} + None => return Ok(()), + } + } + status = self.child.wait() => { + let status = status.context("failed to wait on mitmdump")?; + anyhow::bail!("mitmdump exited early with {status}"); + } + // `if rules_channel_open` stops polling this branch for good once the + // sender is dropped (the plain `run()` path never has one), instead of + // busy-looping on an immediately-ready `Err` forever after. + changed = rules.changed(), if rules_channel_open => { + match changed { + Ok(()) => { + let current = rules.borrow().clone(); + send_fault_rules(&mut write_half, ¤t).await?; + } + Err(_) => rules_channel_open = false, + } + } + } + } + } + + fn log_engine(&self, level: &'static str, message: String) { + eprintln!("mitm_engine: {message}"); + if let Some(log_sink) = &self.log_sink { + log_sink(level, message); + } + } +} + +async fn forward_child_output( + label: &'static str, + output: impl AsyncRead + Unpin, + log_sink: Option, +) { + let mut reader = BufReader::new(output); + let mut line = Vec::new(); + loop { + line.clear(); + match reader.read_until(b'\n', &mut line).await { + Ok(0) => return, + Ok(_) => { + let text = String::from_utf8_lossy(&line); + let text = text.trim_end(); + if should_log_child_output(text) { + eprintln!("{label}: {text}"); + if let Some(log_sink) = &log_sink { + log_sink(log_level_for_child_output(text), format!("{label}: {text}")); + } + } + } + Err(error) => { + eprintln!("{label}: failed to read output: {error}"); + if let Some(log_sink) = &log_sink { + log_sink("error", format!("{label}: failed to read output: {error}")); + } + return; + } + } + } +} + +fn log_level_for_child_output(line: &str) -> &'static str { + if line.contains("Traceback") + || line.contains("Addon error") + || line.contains("Error") + || line.contains("error") + { + "error" + } else { + "info" + } +} + +fn should_log_child_output(line: &str) -> bool { + line.contains("proctap bridge:") + || line.contains("Loading script") + || line.contains("proxy listening") + || line.contains("Addon error") + || line.contains("Traceback") + || line.contains("Error") + || line.contains("error") +} + +/// Pushes the current fault rule set to the addon as one JSON line, mirroring the +/// addon's own one-line-per-message flow protocol but in the opposite direction. +async fn send_fault_rules( + write_half: &mut tokio::net::tcp::OwnedWriteHalf, + rules: &[FaultRule], +) -> Result<()> { + let payload = serde_json::json!({ "rules": rules }); + let mut line = serde_json::to_vec(&payload).context("failed to serialize fault rules")?; + line.push(b'\n'); + write_half + .write_all(&line) + .await + .context("failed to push fault rules to bridge addon")?; + Ok(()) +} + +fn to_captured_exchange(wire: WireExchange, mode: &ProxyMode) -> CapturedExchange { + let (process_pid, process_name) = match mode { + ProxyMode::Local(target) => (target.pid, Some(target.display_name.clone())), + ProxyMode::LocalGroup { display_name, .. } => (None, Some(display_name.clone())), + ProxyMode::Regular { .. } => (None, Some(EXTERNAL_CLIENT_LABEL.to_string())), + }; + + CapturedExchange { + id: wire + .id + .as_deref() + .map(stable_flow_uuid) + .unwrap_or_else(Uuid::new_v4), + pinned: false, + process_pid, + process_name, + method: wire.method, + url: wire.url, + status: wire.status, + request_headers: to_header_pairs(wire.request_headers), + response_headers: to_header_pairs(wire.response_headers), + request_body: decode_body(wire.request_body_b64), + response_body: decode_body(wire.response_body_b64), + duration_ms: wire.duration_ms, + send_duration_ms: wire.send_duration_ms, + wait_duration_ms: wire.wait_duration_ms, + receive_duration_ms: wire.receive_duration_ms, + error: wire.error, + timestamp_ms: wire.timestamp_ms, + streaming: false, + response_body_truncated: wire.response_body_truncated, + } +} + +fn apply_wire_event( + event: WireEvent, + mode: &ProxyMode, + streaming_exchanges: &mut HashMap, +) -> Option { + match event { + WireEvent::Started(wire) => { + let id = stable_flow_uuid(&wire.id); + eprintln!("mitm_engine: SSE started id={id} url={}", wire.url); + let (process_pid, process_name) = process_metadata(mode); + let exchange = CapturedExchange { + id, + pinned: false, + process_pid, + process_name, + method: wire.method, + url: wire.url, + status: wire.status, + request_headers: to_header_pairs(wire.request_headers), + response_headers: to_header_pairs(wire.response_headers), + request_body: decode_body(wire.request_body_b64), + response_body: Some(Vec::new()), + duration_ms: None, + send_duration_ms: None, + wait_duration_ms: None, + receive_duration_ms: None, + error: None, + timestamp_ms: wire.timestamp_ms, + streaming: true, + response_body_truncated: false, + }; + streaming_exchanges.insert(id, exchange.clone()); + Some(exchange) + } + WireEvent::Chunk(wire) => { + let id = stable_flow_uuid(&wire.id); + let Some(exchange) = streaming_exchanges.get_mut(&id) else { + eprintln!("mitm_engine: dropping SSE chunk for unknown id={id}"); + return None; + }; + if let Some(chunk) = decode_body(wire.body_b64) { + let chunk_len = chunk.len(); + exchange + .response_body + .get_or_insert_with(Vec::new) + .extend(chunk); + enforce_stream_body_limit(exchange); + let body_len = exchange.response_body.as_ref().map_or(0, Vec::len); + if body_len == chunk_len || body_len % (10 * 1024) < chunk_len { + eprintln!("mitm_engine: SSE chunk id={id} chunk_bytes={chunk_len} body_bytes={body_len}"); + } + } + exchange.timestamp_ms = wire.timestamp_ms; + Some(exchange.clone()) + } + WireEvent::Done(wire) => { + let id = stable_flow_uuid(&wire.id); + let Some(mut exchange) = streaming_exchanges.remove(&id) else { + eprintln!("mitm_engine: dropping SSE done for unknown id={id}"); + return None; + }; + eprintln!("mitm_engine: SSE done id={id} status={:?}", wire.status); + exchange.status = wire.status; + exchange.response_headers = to_header_pairs(wire.response_headers); + exchange.duration_ms = wire.duration_ms; + exchange.send_duration_ms = wire.send_duration_ms; + exchange.wait_duration_ms = wire.wait_duration_ms; + exchange.receive_duration_ms = wire.receive_duration_ms; + exchange.error = wire.error; + exchange.timestamp_ms = wire.timestamp_ms; + exchange.streaming = false; + Some(exchange) + } + } +} + +fn process_metadata(mode: &ProxyMode) -> (Option, Option) { + match mode { + ProxyMode::Local(target) => (target.pid, Some(target.display_name.clone())), + ProxyMode::LocalGroup { display_name, .. } => (None, Some(display_name.clone())), + ProxyMode::Regular { .. } => (None, Some(EXTERNAL_CLIENT_LABEL.to_string())), + } +} + +fn enforce_stream_body_limit(exchange: &mut CapturedExchange) { + let Some(body) = exchange.response_body.as_mut() else { + return; + }; + if body.len() <= MAX_STREAM_BODY_BYTES { + return; + } + let overflow = body.len() - MAX_STREAM_BODY_BYTES; + body.drain(..overflow); + exchange.response_body_truncated = true; +} + +fn stable_flow_uuid(flow_id: &str) -> Uuid { + if let Ok(uuid) = flow_id.parse() { + return uuid; + } + + let digest = Sha256::digest(flow_id.as_bytes()); + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(&digest[..16]); + bytes[6] = (bytes[6] & 0x0f) | 0x50; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + Uuid::from_bytes(bytes) +} + +fn to_header_pairs(headers: Vec<(String, String)>) -> Vec { + headers + .into_iter() + .map(|(name, value)| HeaderPair { name, value }) + .collect() +} + +fn decode_body(body_b64: Option) -> Option> { + use base64::Engine; + body_b64.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok()) +} + +/// On Linux, `mitmdump --mode local:` redirects traffic at the OS level and +/// needs root: internally it re-execs itself under `sudo` when not already root. In a +/// desktop app that subprocess has no controlling terminal, so `sudo` can't prompt and +/// mitmdump just fails fast with "Failed to elevate privileges" — surfaced today as an +/// opaque "mitmdump exited early" error well after the fact. Checking `euid` up front +/// turns that into an immediate, actionable error instead. +/// +/// Granting the mitmdump binary `CAP_NET_ADMIN`/`CAP_NET_RAW` via `setcap` as a +/// root-free alternative was tried and does not work with mitmproxy's official +/// standalone (PyInstaller onefile) binaries: file capabilities put the binary in +/// secure-exec mode, which makes the dynamic loader ignore `LD_LIBRARY_PATH` and breaks +/// its bundled `libssl.so.1.1` loading (verified: `ImportError: libssl.so.1.1: cannot +/// open shared object file`). Root is the only currently-known working path here. +#[cfg(target_os = "linux")] +fn ensure_linux_capture_privileges() -> Result<()> { + anyhow::ensure!( + is_linux_root(), + "mitmdump's local capture mode needs root on Linux: it redirects traffic at the OS \ + level and elevates itself via `sudo`, which fails without a terminal to prompt on. \ + Run ProcTap itself as root (e.g. launch it with `sudo -E`), or start it via `pkexec`. \ + Granting the mitmdump binary CAP_NET_ADMIN/CAP_NET_RAW via `setcap` does not work \ + around this: it breaks mitmproxy's official standalone binary (verified)." + ); + Ok(()) +} + +/// Whether the current process is running as root, by reading `/proc/self/status` +/// instead of adding a `libc`/`nix` dependency for a single `geteuid()` call. +#[cfg(target_os = "linux")] +pub(crate) fn is_linux_root() -> bool { + std::fs::read_to_string("/proc/self/status") + .ok() + .and_then(|status| { + status.lines().find_map(|line| { + line.strip_prefix("Uid:") + .and_then(|rest| rest.split_whitespace().next()) + .map(|uid| uid == "0") + }) + }) + .unwrap_or(false) +} + +/// Locates a usable `mitmdump` executable: `PROCTAP_MITMDUMP` env var if set, otherwise +/// `mitmdump`/`mitmdump.exe` on `PATH`. +pub fn find_mitmdump() -> Result { + if let Ok(path) = std::env::var("PROCTAP_MITMDUMP") { + return Ok(std::path::PathBuf::from(path)); + } + + let exe_name = if cfg!(windows) { + "mitmdump.exe" + } else { + "mitmdump" + }; + which::which(exe_name).with_context(|| { + format!("{exe_name} not found on PATH; install mitmproxy or set PROCTAP_MITMDUMP") + }) +} diff --git a/crates/mitm_engine/src/trust.rs b/crates/mitm_engine/src/trust.rs new file mode 100644 index 0000000..4fc8aff --- /dev/null +++ b/crates/mitm_engine/src/trust.rs @@ -0,0 +1,213 @@ +//! CA trust automation for milestone 4: install the CA that mitmdump generates +//! in its confdir into the OS trust store and, for JVM targets, into JDK +//! truststores — automatically, instead of the old manual doc-driven step. + +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +/// Path to the CA certificate mitmdump writes into its confdir on first run. +pub fn ca_cert_path(confdir: &Path) -> PathBuf { + confdir.join("mitmproxy-ca-cert.pem") +} + +/// Trust status of mitmdump's own CA, as reported to the UI. `available` is +/// false until a capture session has run at least once, since that's when +/// mitmdump first writes its CA into the confdir. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CaStatus { + pub available: bool, + pub cert_path: String, + pub fingerprint_sha256: String, +} + +/// Reads CA status straight from `confdir` on disk. +pub fn ca_status(confdir: &Path) -> CaStatus { + let cert_path = ca_cert_path(confdir); + + match std::fs::read(&cert_path) { + Ok(bytes) => CaStatus { + available: true, + cert_path: cert_path.display().to_string(), + fingerprint_sha256: fingerprint_sha256(&bytes), + }, + Err(_) => CaStatus { + available: false, + cert_path: cert_path.display().to_string(), + fingerprint_sha256: String::new(), + }, + } +} + +fn fingerprint_sha256(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + digest + .iter() + .map(|byte| format!("{byte:02X}")) + .collect::>() + .join(":") +} + +/// Installs `cert_path` into the OS trust store. On Windows and macOS this is a +/// per-user install that never needs admin/root (current-user Root store / login +/// keychain, respectively). Linux has no equivalent no-root system-wide mechanism +/// on Debian/Ubuntu (see the Linux branch below) — but capturing at all already +/// requires ProcTap to run as root there (`ensure_linux_capture_privileges`), so +/// in practice this always runs as root on Linux and can just use the standard +/// root-only `update-ca-certificates` path directly. +pub fn install_os_trust(cert_path: &Path) -> Result { + anyhow::ensure!( + cert_path.exists(), + "CA certificate not found at {}", + cert_path.display() + ); + + #[cfg(windows)] + { + let output = Command::new("certutil") + .args(["-user", "-addstore", "-f", "Root"]) + .arg(cert_path) + .output() + .context("failed to run certutil")?; + return finish("certutil", output, "OS trust store (current user)"); + } + + #[cfg(target_os = "macos")] + { + let keychain = std::env::var("HOME") + .map(|home| format!("{home}/Library/Keychains/login.keychain-db")) + .context("HOME is not set")?; + let output = Command::new("security") + .args(["add-trusted-cert", "-d", "-r", "trustRoot", "-k", &keychain]) + .arg(cert_path) + .output() + .context("failed to run security add-trusted-cert")?; + return finish( + "security add-trusted-cert", + output, + "OS trust store (login keychain)", + ); + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + // If we're root, `--mode local:` already required it (see + // `ensure_linux_capture_privileges`), so just do the real system-wide + // install: verified live on Ubuntu 22.04 (`update-ca-certificates` picks + // up anything under this directory) that a plain `curl` as a normal user + // then trusts a leaf cert signed by this CA with no `-k`/`--cacert`. + if crate::is_linux_root() { + return install_linux_system_trust(cert_path); + } + + // Not root: p11-kit's per-user anchor works on some distros (Fedora/RHEL + // configure a writable anchor location), so try it before giving up. + if let Ok(output) = Command::new("trust") + .args(["anchor", "--store"]) + .arg(cert_path) + .output() + { + if output.status.success() { + return Ok("Installed CA into OS trust store (p11-kit user anchor).".to_string()); + } + } + + // Verified on Ubuntu 22.04: `trust anchor --store` fails there with + // "no configured writable location to store anchors" — Debian/Ubuntu + // don't wire up p11-kit's anchor mechanism the way Fedora/RHEL do, and + // there is no other no-root, no-prompt system-wide trust install on + // that distro family. Root is the only reliable path, so report exactly + // what to run instead of pretending this succeeded. + bail!( + "Could not install the CA into the OS trust store without root (p11-kit has no \ + writable per-user anchor location on this system). Run ProcTap as root to install \ + it automatically, or run this manually:\n sudo cp {cert} \ + /usr/local/share/ca-certificates/proctap-mitmproxy-ca.crt && sudo update-ca-certificates", + cert = cert_path.display() + ); + } +} + +/// Installs `cert_path` system-wide via `update-ca-certificates`, the standard +/// Debian/Ubuntu mechanism (root-only). Verified live: after running this, a +/// non-root `curl` trusts a leaf certificate signed by the installed CA with no +/// `-k`/`--cacert` flag needed. +#[cfg(all(unix, not(target_os = "macos")))] +fn install_linux_system_trust(cert_path: &Path) -> Result { + let dest = Path::new("/usr/local/share/ca-certificates/proctap-mitmproxy-ca.crt"); + std::fs::copy(cert_path, dest) + .with_context(|| format!("failed to copy CA certificate to {}", dest.display()))?; + + let output = Command::new("update-ca-certificates") + .output() + .context("failed to run update-ca-certificates")?; + finish( + "update-ca-certificates", + output, + "OS trust store (system-wide, via update-ca-certificates)", + ) +} + +fn finish(tool: &str, output: Output, target: &str) -> Result { + if output.status.success() { + Ok(format!("Installed CA into {target}.")) + } else { + bail!( + "{tool} failed ({}): {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } +} + +#[cfg(windows)] +const JDK_TRUSTSTORE_SCRIPT: &str = "scripts/manage-jdk-truststore.ps1"; +#[cfg(not(windows))] +const JDK_TRUSTSTORE_SCRIPT: &str = "scripts/manage-jdk-truststore.sh"; + +/// Imports `cert_path` into every JDK truststore this machine can discover, +/// non-interactively, via `manage-jdk-truststore`. +pub fn install_jdk_trust(workspace_root: &Path, cert_path: &Path) -> Result { + let script = workspace_root.join(JDK_TRUSTSTORE_SCRIPT); + anyhow::ensure!( + script.exists(), + "truststore script not found at {}", + script.display() + ); + + #[cfg(windows)] + let output = Command::new("powershell") + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]) + .arg(&script) + .args(["-Action", "import", "-All", "-CaCertPath"]) + .arg(cert_path) + .output() + .context("failed to run manage-jdk-truststore.ps1")?; + + #[cfg(not(windows))] + let output = Command::new("bash") + .arg(&script) + .args(["-a", "import", "--all", "--ca-cert"]) + .arg(cert_path) + .output() + .context("failed to run manage-jdk-truststore.sh")?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if output.status.success() { + Ok(if stdout.is_empty() { + "JDK truststores updated.".to_string() + } else { + stdout + }) + } else { + bail!( + "manage-jdk-truststore exited with {}: {}{}", + output.status, + stdout, + String::from_utf8_lossy(&output.stderr).trim() + ); + } +} diff --git a/crates/processes/Cargo.toml b/crates/processes/Cargo.toml new file mode 100644 index 0000000..b269403 --- /dev/null +++ b/crates/processes/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "processes" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +serde.workspace = true +sysinfo = "0.30" + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_UI_WindowsAndMessaging", +] } diff --git a/crates/processes/src/lib.rs b/crates/processes/src/lib.rs new file mode 100644 index 0000000..c2ec986 --- /dev/null +++ b/crates/processes/src/lib.rs @@ -0,0 +1,231 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use sysinfo::{ProcessRefreshKind, RefreshKind, System, UpdateKind}; + +#[cfg(windows)] +mod window_titles; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProcessAncestor { + pub pid: u32, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProcessInfo { + pub pid: u32, + pub name: String, + pub command: String, + pub parent_pid: Option, + pub parent_name: Option, + /// Short best-effort parent chain starting at the direct parent. This helps + /// users distinguish same-named shells launched from IntelliJ, Windows + /// Terminal, classic consoles, scripts, etc. + pub ancestor_chain: Vec, + pub is_java: bool, + /// Best-effort JVM artifact name extracted from the command line, e.g. + /// `app.jar` from `java -jar C:\apps\app.jar`. + pub java_artifact_name: Option, + /// Best-effort JVM main class extracted when the process was launched from + /// classes instead of a packaged artifact, e.g. IntelliJ Spring Boot runs. + pub java_main_class: Option, + /// Title of a visible top-level window owned by this process, when one exists + /// (e.g. a browser tab group's window title). Lets the picker show something + /// more useful than a bare PID when several instances of the same process name + /// are running (multiple Chrome windows, multiple JVMs, ...). + pub window_title: Option, +} + +/// Lists all OS processes for the capture target picker, tagging JVM processes so +/// the UI can surface a badge (JVM targets still need truststore automation since +/// many JVMs ignore the OS trust store). +pub fn list_processes() -> Result> { + let system = System::new_with_specifics( + RefreshKind::new().with_processes(ProcessRefreshKind::new().with_cmd(UpdateKind::Always)), + ); + + #[cfg(windows)] + let mut window_titles = window_titles::by_pid(); + + let mut processes = system + .processes() + .iter() + .map(|(pid, process)| { + let name = process.name().to_string(); + let command_parts = process + .cmd() + .iter() + .cloned() + .collect::>(); + let command = command_parts.join(" "); + let searchable = format!("{name} {command}").to_ascii_lowercase(); + let is_java = is_java_process(&searchable); + let java_artifact_name = is_java + .then(|| java_artifact_name(&command_parts)) + .flatten(); + let java_main_class = is_java + .then(|| java_main_class(&command_parts)) + .flatten(); + let pid = pid.as_u32(); + let ancestor_chain = process_ancestor_chain(&system, process.parent()); + let parent_pid = ancestor_chain.first().map(|parent| parent.pid); + let parent_name = ancestor_chain.first().map(|parent| parent.name.clone()); + + #[cfg(windows)] + let window_title = window_titles.remove(&pid).map(|titles| titles.join(" | ")); + #[cfg(not(windows))] + let window_title = None; + + ProcessInfo { + pid, + name, + command, + parent_pid, + parent_name, + ancestor_chain, + is_java, + java_artifact_name, + java_main_class, + window_title, + } + }) + .collect::>(); + + processes.sort_by(|left, right| left.name.cmp(&right.name).then(left.pid.cmp(&right.pid))); + + Ok(processes) +} + +fn process_ancestor_chain(system: &System, parent: Option) -> Vec { + let mut ancestors = Vec::new(); + let mut next = parent; + + while let Some(pid) = next { + let Some(process) = system.process(pid) else { + break; + }; + + ancestors.push(ProcessAncestor { + pid: pid.as_u32(), + name: process.name().to_string(), + }); + + if ancestors.len() >= 5 { + break; + } + + next = process.parent(); + } + + ancestors +} + +fn is_java_process(searchable: &str) -> bool { + searchable.contains("java") + || searchable.contains("java.exe") + || searchable.contains("javaw") + || searchable.contains("javaw.exe") +} + +/// Whether a capture target name (e.g. entered in the process picker) looks +/// like a JVM process, for triggering JDK truststore automation. +pub fn is_java_process_name(name: &str) -> bool { + is_java_process(&name.to_ascii_lowercase()) +} + +fn java_artifact_name(command_parts: &[String]) -> Option { + let artifact_after_jar = command_parts + .windows(2) + .find(|window| window[0] == "-jar") + .and_then(|window| artifact_basename(&window[1])); + + artifact_after_jar.or_else(|| command_parts.iter().find_map(|part| artifact_basename(part))) +} + +fn artifact_basename(raw: &str) -> Option { + let cleaned = raw.trim_matches(|c| c == '"' || c == '\''); + let lower = cleaned.to_ascii_lowercase(); + if !lower.ends_with(".jar") && !lower.ends_with(".war") { + return None; + } + + cleaned + .rsplit(['/', '\\']) + .next() + .filter(|name| !name.is_empty()) + .map(str::to_string) +} + +fn java_main_class(command_parts: &[String]) -> Option { + command_parts + .iter() + .rev() + .find(|part| is_main_class_candidate(part)) + .cloned() +} + +fn is_main_class_candidate(raw: &str) -> bool { + let cleaned = raw.trim_matches(|c| c == '"' || c == '\''); + if cleaned.starts_with('-') + || cleaned.starts_with('@') + || cleaned.contains('/') + || cleaned.contains('\\') + || cleaned.contains(':') + || cleaned.to_ascii_lowercase().ends_with(".jar") + || cleaned.to_ascii_lowercase().ends_with(".war") + { + return false; + } + + cleaned.contains('.') + && cleaned + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '$')) +} + +#[cfg(test)] +mod tests { + use super::{java_artifact_name, java_main_class}; + + #[test] + fn extracts_jar_after_dash_jar() { + let command = vec!["java".into(), "-jar".into(), r"C:\apps\demo.jar".into()]; + + assert_eq!(java_artifact_name(&command), Some("demo.jar".into())); + } + + #[test] + fn extracts_war_after_dash_jar() { + let command = vec!["java".into(), "-jar".into(), "/srv/app.war".into()]; + + assert_eq!(java_artifact_name(&command), Some("app.war".into())); + } + + #[test] + fn falls_back_to_first_artifact_argument() { + let command = vec!["java".into(), "-Dloader.path=lib".into(), "service.jar".into()]; + + assert_eq!(java_artifact_name(&command), Some("service.jar".into())); + } + + #[test] + fn extracts_intellij_main_class_after_arg_file() { + let command = vec![ + "java".into(), + "-Dspring.output.ansi.enabled=always".into(), + "@C:\\Users\\david\\AppData\\Local\\Temp\\idea_arg_file1741780941".into(), + "nc.opt.psp.PspApp".into(), + ]; + + assert_eq!(java_main_class(&command), Some("nc.opt.psp.PspApp".into())); + } + + #[test] + fn ignores_artifact_when_extracting_main_class() { + let command = vec!["java".into(), "-jar".into(), r"C:\apps\demo.jar".into()]; + + assert_eq!(java_main_class(&command), None); + } +} diff --git a/crates/processes/src/window_titles.rs b/crates/processes/src/window_titles.rs new file mode 100644 index 0000000..7ec4927 --- /dev/null +++ b/crates/processes/src/window_titles.rs @@ -0,0 +1,56 @@ +use std::collections::HashMap; + +use windows::core::BOOL; +use windows::Win32::Foundation::{HWND, LPARAM}; +use windows::Win32::UI::WindowsAndMessaging::{ + EnumWindows, GetWindow, GetWindowTextLengthW, GetWindowTextW, GetWindowThreadProcessId, + IsWindowVisible, GW_OWNER, +}; + +/// Maps each PID that owns at least one visible, unowned top-level window to +/// that window's title(s). Only top-level app windows are considered (owned +/// windows such as dialogs/popups are skipped), which is why background +/// helper/renderer processes (e.g. Chrome's per-tab renderer processes) are +/// absent from the result: only the browser process that actually owns the +/// window shows up here. +pub fn by_pid() -> HashMap> { + let mut titles: HashMap> = HashMap::new(); + + unsafe { + let _ = EnumWindows(Some(enum_proc), LPARAM(&mut titles as *mut _ as isize)); + } + + titles +} + +unsafe extern "system" fn enum_proc(hwnd: HWND, lparam: LPARAM) -> BOOL { + let titles = &mut *(lparam.0 as *mut HashMap>); + + if !IsWindowVisible(hwnd).as_bool() { + return BOOL(1); + } + + if !GetWindow(hwnd, GW_OWNER).unwrap_or_default().is_invalid() { + return BOOL(1); + } + + let len = GetWindowTextLengthW(hwnd); + if len == 0 { + return BOOL(1); + } + + let mut buffer = vec![0u16; len as usize + 1]; + let copied = GetWindowTextW(hwnd, &mut buffer); + if copied == 0 { + return BOOL(1); + } + let title = String::from_utf16_lossy(&buffer[..copied as usize]); + + let mut pid = 0u32; + GetWindowThreadProcessId(hwnd, Some(&mut pid)); + if pid != 0 { + titles.entry(pid).or_default().push(title); + } + + BOOL(1) +} diff --git a/crates/proxy/src/lib.rs b/crates/proxy/src/lib.rs deleted file mode 100644 index 062015f..0000000 --- a/crates/proxy/src/lib.rs +++ /dev/null @@ -1,1091 +0,0 @@ -use anyhow::Context; -use capture::{CapturedExchange, HeaderPair}; -use flate2::read::{DeflateDecoder, GzDecoder}; -use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; -use rustls::{ClientConfig, RootCertStore, ServerConfig}; -use serde::{Deserialize, Serialize}; -use std::io::{BufReader, ErrorKind, Read}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::sync::{Mutex, OnceLock}; -use std::time::Instant; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; -use tokio::net::{TcpListener, TcpStream}; -use tokio_rustls::{TlsAcceptor, TlsConnector}; -use uuid::Uuid; - -pub const DEFAULT_BIND_ADDRESS: &str = "127.0.0.1:8877"; - -static START_REQUESTED: AtomicBool = AtomicBool::new(false); -static RUNNING: AtomicBool = AtomicBool::new(false); -static CAPTURES: OnceLock>> = OnceLock::new(); -static CAPTURE_SINK: OnceLock> = OnceLock::new(); - -pub fn set_capture_sink(sink: impl Fn(&CapturedExchange) + Send + Sync + 'static) { - let _ = CAPTURE_SINK.set(Box::new(sink)); -} - -pub fn preload_exchanges(exchanges: Vec) { - let mut store = captures().lock().expect("capture store poisoned"); - store.extend(exchanges); -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProxyStatus { - pub running: bool, - pub bind_address: String, -} - -pub fn status() -> ProxyStatus { - ProxyStatus { - running: RUNNING.load(Ordering::SeqCst), - bind_address: DEFAULT_BIND_ADDRESS.to_string(), - } -} - -pub fn captured_exchanges() -> Vec { - captures().lock().expect("capture store poisoned").clone() -} - -pub fn clear_captures() { - captures().lock().expect("capture store poisoned").clear(); -} - -pub async fn start_default() -> anyhow::Result<()> { - start(DEFAULT_BIND_ADDRESS).await -} - -pub async fn start(bind_address: &str) -> anyhow::Result<()> { - if START_REQUESTED.swap(true, Ordering::SeqCst) { - return Ok(()); - } - - let listener = match TcpListener::bind(bind_address).await { - Ok(listener) => listener, - Err(error) => { - START_REQUESTED.store(false, Ordering::SeqCst); - return Err(error.into()); - } - }; - - RUNNING.store(true, Ordering::SeqCst); - - tokio::spawn(async move { - loop { - match listener.accept().await { - Ok((stream, _)) => { - tokio::spawn(async move { - if let Err(error) = handle_client(stream).await { - eprintln!("proxy connection failed: {error}"); - } - }); - } - Err(error) => { - RUNNING.store(false, Ordering::SeqCst); - START_REQUESTED.store(false, Ordering::SeqCst); - eprintln!("proxy accept failed: {error}"); - break; - } - } - } - }); - - Ok(()) -} - -async fn handle_client(mut client: TcpStream) -> anyhow::Result<()> { - let started_at = Instant::now(); - let request = read_http_request(&mut client).await?; - let header_text = String::from_utf8_lossy(&request.headers); - let mut lines = header_text.split("\r\n"); - let request_line = lines.next().unwrap_or_default(); - let mut request_parts = request_line.split_whitespace(); - let method = request_parts.next().unwrap_or_default(); - let target = request_parts.next().unwrap_or_default(); - let version = request_parts.next().unwrap_or("HTTP/1.1"); - - if method.eq_ignore_ascii_case("CONNECT") { - return handle_connect(client, target, &header_text, started_at).await; - } - - let Some(destination) = parse_http_destination(target, &header_text) else { - client - .write_all(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 27\r\nConnection: close\r\n\r\nInvalid proxy request target\n") - .await?; - return Ok(()); - }; - - let url = format!( - "http://{}:{}{}", - destination.host, destination.port, destination.path - ); - let request_headers = parse_headers(&header_text); - let mut upstream = TcpStream::connect((&destination.host[..], destination.port)).await?; - let forwarded_headers = rewrite_headers(method, &destination.path, version, &header_text); - - upstream.write_all(forwarded_headers.as_bytes()).await?; - upstream.write_all(&request.body).await?; - upstream.flush().await?; - - let response = read_http_response(&mut upstream).await?; - client.write_all(&response).await?; - client.flush().await?; - - let (status, response_headers, response_body) = parse_response(&response); - push_capture(CapturedExchange { - id: Uuid::new_v4(), - process_pid: None, - process_name: None, - method: method.to_string(), - url, - status, - request_headers, - response_headers, - request_body: (!request.body.is_empty()).then_some(request.body), - response_body: (!response_body.is_empty()).then_some(response_body), - duration_ms: Some(started_at.elapsed().as_millis() as u64), - error: None, - }); - - Ok(()) -} - -async fn handle_connect( - mut client: TcpStream, - target: &str, - header_text: &str, - started_at: Instant, -) -> anyhow::Result<()> { - let request_headers = parse_headers(header_text); - let Some((host, port)) = split_host_port(target, 443) else { - client - .write_all(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 28\r\nConnection: close\r\n\r\nInvalid CONNECT destination\n") - .await?; - return Ok(()); - }; - let url = format!("https://{host}:{port}"); - - let tcp_upstream = match TcpStream::connect((&host[..], port)).await { - Ok(upstream) => upstream, - Err(error) => { - client - .write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 29\r\nConnection: close\r\n\r\nFailed to connect upstream\n") - .await?; - push_capture(CapturedExchange { - id: Uuid::new_v4(), - process_pid: None, - process_name: None, - method: "CONNECT".to_string(), - url, - status: Some(502), - request_headers, - response_headers: Vec::new(), - request_body: None, - response_body: None, - duration_ms: Some(started_at.elapsed().as_millis() as u64), - error: Some(upstream_connect_error_message(&host, port, &error)), - }); - return Ok(()); - } - }; - - drop(tcp_upstream); - client - .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") - .await?; - - if let Err(error) = handle_https_mitm(client, &host, port, started_at).await { - eprintln!("https mitm failed for {host}:{port}: {error:#}"); - push_capture(CapturedExchange { - id: Uuid::new_v4(), - process_pid: None, - process_name: None, - method: "CONNECT".to_string(), - url, - status: Some(502), - request_headers, - response_headers: Vec::new(), - request_body: None, - response_body: None, - duration_ms: Some(started_at.elapsed().as_millis() as u64), - error: Some(https_mitm_error_message(&host, port, &error)), - }); - return Err(error); - } - - Ok(()) -} - -async fn handle_https_mitm( - client: TcpStream, - host: &str, - port: u16, - _started_at: Instant, -) -> anyhow::Result<()> { - let acceptor = TlsAcceptor::from(Arc::new(server_tls_config(host)?)); - let mut client_tls = acceptor - .accept(client) - .await - .with_context(|| format!("client TLS handshake failed for {host}:{port}"))?; - let connector = Arc::new(client_tls_config()); - - loop { - let request = match try_read_http_request(&mut client_tls).await { - Ok(Some(req)) => req, - Ok(None) => break, - Err(e) => { - return Err(e.context(format!( - "failed to read HTTPS request from client for {host}:{port}" - ))) - } - }; - - let request_started_at = Instant::now(); - let header_text = String::from_utf8_lossy(&request.headers); - let mut lines = header_text.split("\r\n"); - let request_line = lines.next().unwrap_or_default(); - let mut request_parts = request_line.split_whitespace(); - let method = request_parts.next().unwrap_or_default(); - let target = request_parts.next().unwrap_or_default(); - let version = request_parts.next().unwrap_or("HTTP/1.1"); - let path = if target.starts_with('/') { target } else { "/" }; - let url = format!("https://{host}:{port}{path}"); - let request_headers = parse_headers(&header_text); - let client_wants_close = wants_connection_close(&header_text); - let forwarded_headers = rewrite_headers(method, path, version, &header_text); - - let tcp_upstream = TcpStream::connect((host, port)) - .await - .with_context(|| format!("failed to connect upstream for {host}:{port}"))?; - let server_name = ServerName::try_from(host.to_string())?; - let mut upstream_tls = TlsConnector::from(Arc::clone(&connector)) - .connect(server_name, tcp_upstream) - .await - .with_context(|| format!("upstream TLS handshake failed for {host}:{port}"))?; - - upstream_tls - .write_all(forwarded_headers.as_bytes()) - .await - .with_context(|| { - format!("failed to write HTTPS request headers upstream for {host}:{port}") - })?; - upstream_tls - .write_all(&request.body) - .await - .with_context(|| { - format!("failed to write HTTPS request body upstream for {host}:{port}") - })?; - upstream_tls - .flush() - .await - .with_context(|| format!("failed to flush HTTPS request upstream for {host}:{port}"))?; - - let response = read_http_response(&mut upstream_tls) - .await - .with_context(|| format!("failed to read HTTPS response upstream for {host}:{port}"))?; - - let response_header_end = find_header_end(&response).unwrap_or(response.len()); - let response_header_text = String::from_utf8_lossy(&response[..response_header_end]); - let server_wants_close = wants_connection_close(&response_header_text); - - client_tls.write_all(&response).await.with_context(|| { - format!("failed to write HTTPS response to client for {host}:{port}") - })?; - client_tls.flush().await.with_context(|| { - format!("failed to flush HTTPS response to client for {host}:{port}") - })?; - - let (status, response_headers, response_body) = parse_response(&response); - push_capture(CapturedExchange { - id: Uuid::new_v4(), - process_pid: None, - process_name: None, - method: method.to_string(), - url, - status, - request_headers, - response_headers, - request_body: (!request.body.is_empty()).then_some(request.body), - response_body: (!response_body.is_empty()).then_some(response_body), - duration_ms: Some(request_started_at.elapsed().as_millis() as u64), - error: None, - }); - - if client_wants_close || server_wants_close { - break; - } - } - - Ok(()) -} - -fn server_tls_config(host: &str) -> anyhow::Result { - let certificate = certs::load_host_certificate(host)?; - let cert_chain = parse_certificates(&certificate.cert_pem)?; - let private_key = parse_private_key(&certificate.key_pem)?; - - let mut config = ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(cert_chain, private_key)?; - config.alpn_protocols = vec![b"http/1.1".to_vec()]; - Ok(config) -} - -fn client_tls_config() -> ClientConfig { - let mut roots = RootCertStore::empty(); - let native_certs = rustls_native_certs::load_native_certs(); - - for certificate in native_certs.certs { - let _ = roots.add(certificate); - } - - roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - - let mut config = ClientConfig::builder() - .with_root_certificates(roots) - .with_no_client_auth(); - config.alpn_protocols = vec![b"http/1.1".to_vec()]; - config -} - -fn parse_certificates(pem: &str) -> anyhow::Result>> { - let mut reader = BufReader::new(pem.as_bytes()); - rustls_pemfile::certs(&mut reader) - .collect::, _>>() - .map_err(Into::into) -} - -fn parse_private_key(pem: &str) -> anyhow::Result> { - let mut reader = BufReader::new(pem.as_bytes()); - rustls_pemfile::private_key(&mut reader)?.ok_or_else(|| anyhow::anyhow!("missing private key")) -} - -fn upstream_connect_error_message(host: &str, port: u16, error: &std::io::Error) -> String { - format!( - "Phase: Upstream TCP connect\nLikely cause: The target host is unreachable, refused the connection, or DNS/network access failed.\nSuggested action: Verify the application can reach {host}:{port} without OpenIntercept, then retry through the proxy.\nTechnical error: {error}" - ) -} - -fn https_mitm_error_message(host: &str, port: u16, error: &anyhow::Error) -> String { - let technical_error = format!("{error:#}"); - let lower_error = technical_error.to_ascii_lowercase(); - let (phase, likely_cause, suggested_action) = if lower_error - .contains("client tls handshake failed") - { - ( - "Client TLS handshake", - "The Java client likely does not trust the OpenIntercept CA, uses a custom SSL context, pins certificates, or did not negotiate HTTP/1.1.", - "Import the OpenIntercept CA into the JDK or application truststore, restart the Java HTTP client so it rebuilds SSL/proxy state, and check for certificate pinning or custom SSL contexts.", - ) - } else if lower_error.contains("upstream tls handshake failed") { - ( - "Upstream TLS handshake", - "OpenIntercept could not establish TLS with the real upstream server. The server certificate, SNI, protocol support, or local trust roots may be incompatible.", - "Verify the target endpoint certificate and protocol support outside OpenIntercept, then check whether the endpoint requires a custom CA or client certificate.", - ) - } else if lower_error.contains("failed to read https request from client") { - ( - "Read HTTPS request", - "The client closed the tunnel or sent data OpenIntercept could not parse as HTTP/1.1 after TLS was established.", - "Check whether the client attempted HTTP/2, reused a stale connection, or closed the request early.", - ) - } else if lower_error.contains("failed to connect upstream") { - ( - "Upstream TCP connect", - "The target host became unreachable after the CONNECT tunnel was accepted.", - "Verify the target endpoint is reachable from this machine and retry the request.", - ) - } else if lower_error.contains("failed to read https response upstream") { - ( - "Read upstream response", - "The upstream server closed the connection, streamed a response OpenIntercept could not finish reading, or returned unsupported framing.", - "Retry the request and check whether the endpoint uses large streaming responses or HTTP/2.", - ) - } else if lower_error.contains("failed to write https response to client") - || lower_error.contains("failed to flush https response to client") - { - ( - "Write response to client", - "The client closed the connection before OpenIntercept could forward the upstream response.", - "Check client timeouts, cancellations, and whether the request was aborted by the application.", - ) - } else { - ( - "HTTPS MITM", - "OpenIntercept failed while proxying the HTTPS tunnel.", - "Check the technical error, CA trust, endpoint reachability, HTTP/2 usage, and custom Java SSL/proxy configuration.", - ) - }; - - format!( - "Phase: {phase}\nHost: {host}:{port}\nLikely cause: {likely_cause}\nSuggested action: {suggested_action}\nTechnical error: {technical_error}" - ) -} - -fn captures() -> &'static Mutex> { - CAPTURES.get_or_init(|| Mutex::new(Vec::new())) -} - -fn push_capture(exchange: CapturedExchange) { - if let Some(sink) = CAPTURE_SINK.get() { - sink(&exchange); - } - let mut captures = captures().lock().expect("capture store poisoned"); - captures.push(exchange); - if captures.len() > 500 { - captures.remove(0); - } -} - -struct HttpRequestBytes { - headers: Vec, - body: Vec, -} - -async fn read_http_request(client: &mut T) -> anyhow::Result -where - T: AsyncRead + Unpin, -{ - let mut buffer = Vec::with_capacity(8192); - let header_end = loop { - let mut chunk = [0_u8; 1024]; - let bytes_read = client.read(&mut chunk).await?; - - if bytes_read == 0 { - anyhow::bail!("client closed before sending request headers"); - } - - buffer.extend_from_slice(&chunk[..bytes_read]); - - if let Some(position) = find_header_end(&buffer) { - break position; - } - - if buffer.len() > 1024 * 1024 { - anyhow::bail!("request headers exceed 1 MiB"); - } - }; - - let headers = buffer[..header_end].to_vec(); - let body_start = header_end + 4; - let body = buffer[body_start..].to_vec(); - let header_text = String::from_utf8_lossy(&headers); - let body = read_http_request_body(client, &header_text, body).await?; - - Ok(HttpRequestBytes { headers, body }) -} - -fn wants_connection_close(header_text: &str) -> bool { - header_text.lines().any(|line| { - if let Some((name, value)) = line.split_once(':') { - name.trim().eq_ignore_ascii_case("connection") - && value.trim().eq_ignore_ascii_case("close") - } else { - false - } - }) -} - -async fn try_read_http_request(client: &mut T) -> anyhow::Result> -where - T: AsyncRead + Unpin, -{ - let mut buffer = Vec::with_capacity(8192); - let header_end = loop { - let mut chunk = [0_u8; 1024]; - let bytes_read = client.read(&mut chunk).await?; - - if bytes_read == 0 { - if buffer.is_empty() { - return Ok(None); - } - anyhow::bail!("client closed mid-request"); - } - - buffer.extend_from_slice(&chunk[..bytes_read]); - - if let Some(position) = find_header_end(&buffer) { - break position; - } - - if buffer.len() > 1024 * 1024 { - anyhow::bail!("request headers exceed 1 MiB"); - } - }; - - let headers = buffer[..header_end].to_vec(); - let body_start = header_end + 4; - let body = buffer[body_start..].to_vec(); - let header_text = String::from_utf8_lossy(&headers); - let body = read_http_request_body(client, &header_text, body).await?; - - Ok(Some(HttpRequestBytes { headers, body })) -} - -async fn read_http_request_body( - client: &mut T, - header_text: &str, - mut body: Vec, -) -> anyhow::Result> -where - T: AsyncRead + Unpin, -{ - if is_chunked(header_text) { - return read_chunked_body(client, body).await; - } - - let content_length = content_length(header_text).unwrap_or(0); - - while body.len() < content_length { - let remaining = content_length - body.len(); - let mut chunk = vec![0_u8; remaining.min(8192)]; - let bytes_read = client.read(&mut chunk).await?; - - if bytes_read == 0 { - anyhow::bail!("client closed before sending full request body"); - } - - body.extend_from_slice(&chunk[..bytes_read]); - } - - Ok(body) -} - -fn is_unexpected_tls_eof(error: &std::io::Error) -> bool { - error.kind() == ErrorKind::UnexpectedEof - || error - .to_string() - .contains("peer closed connection without sending TLS close_notify") -} - -fn find_crlf(data: &[u8]) -> Option { - data.windows(2).position(|w| w == b"\r\n") -} - -fn is_chunked(header_text: &str) -> bool { - header_text.lines().any(|line| { - if let Some((name, value)) = line.split_once(':') { - name.trim().eq_ignore_ascii_case("transfer-encoding") - && value.trim().eq_ignore_ascii_case("chunked") - } else { - false - } - }) -} - -fn decode_chunked_body(body: &[u8]) -> Vec { - let mut decoded = Vec::new(); - let mut pos = 0; - - loop { - let Some(crlf) = find_crlf(&body[pos..]) else { - break; - }; - let size_str = std::str::from_utf8(&body[pos..pos + crlf]) - .unwrap_or("") - .trim() - .split(';') - .next() - .unwrap_or("") - .trim(); - let chunk_size = match usize::from_str_radix(size_str, 16) { - Ok(n) => n, - Err(_) => break, - }; - - pos += crlf + 2; - - if chunk_size == 0 { - break; - } - - let end = pos + chunk_size; - if end > body.len() { - decoded.extend_from_slice(&body[pos..]); - break; - } - - decoded.extend_from_slice(&body[pos..end]); - pos = end + 2; - } - - decoded -} - -async fn read_more(stream: &mut T, buffer: &mut Vec) -> anyhow::Result -where - T: AsyncRead + Unpin, -{ - let mut chunk = [0_u8; 8192]; - match stream.read(&mut chunk).await { - Ok(0) => Ok(false), - Ok(n) => { - buffer.extend_from_slice(&chunk[..n]); - Ok(true) - } - Err(e) if is_unexpected_tls_eof(&e) => Ok(false), - Err(e) => Err(e.into()), - } -} - -async fn read_chunked_body(stream: &mut T, mut raw: Vec) -> anyhow::Result> -where - T: AsyncRead + Unpin, -{ - let mut pos = 0; - - loop { - let size_end = loop { - if let Some(p) = find_crlf(&raw[pos..]) { - break pos + p; - } - if !read_more(stream, &mut raw).await? { - return Ok(raw); - } - }; - - let size_str = std::str::from_utf8(&raw[pos..size_end]) - .unwrap_or("") - .trim() - .split(';') - .next() - .unwrap_or("") - .trim(); - - let chunk_size = match usize::from_str_radix(size_str, 16) { - Ok(n) => n, - Err(_) => return Ok(raw), - }; - - pos = size_end + 2; - - if chunk_size == 0 { - loop { - if find_crlf(&raw[pos..]).is_some() { - return Ok(raw); - } - if !read_more(stream, &mut raw).await? { - return Ok(raw); - } - } - } - - let chunk_end = pos + chunk_size + 2; - while raw.len() < chunk_end { - if !read_more(stream, &mut raw).await? { - return Ok(raw); - } - } - - pos = chunk_end; - } -} - -async fn read_http_response(stream: &mut T) -> anyhow::Result> -where - T: AsyncRead + Unpin, -{ - let mut buffer = Vec::with_capacity(8192); - let header_end = loop { - if let Some(pos) = find_header_end(&buffer) { - break pos; - } - if !read_more(stream, &mut buffer).await? { - return Ok(buffer); - } - if buffer.len() > 1024 * 1024 { - anyhow::bail!("response headers exceed 1 MiB"); - } - }; - - let header_text = String::from_utf8_lossy(&buffer[..header_end]); - let body_start = header_end + 4; - let already_buffered = buffer[body_start..].to_vec(); - - let body_raw = if is_chunked(&header_text) { - read_chunked_body(stream, already_buffered).await? - } else if let Some(length) = content_length(&header_text) { - let mut body = already_buffered; - while body.len() < length { - if !read_more(stream, &mut body).await? { - break; - } - } - body - } else { - let mut body = already_buffered; - while read_more(stream, &mut body).await? {} - body - }; - - let mut response = buffer[..body_start].to_vec(); - response.extend_from_slice(&body_raw); - Ok(response) -} - -fn find_header_end(buffer: &[u8]) -> Option { - buffer.windows(4).position(|window| window == b"\r\n\r\n") -} - -fn content_length(header_text: &str) -> Option { - header_text.lines().find_map(|line| { - let (name, value) = line.split_once(':')?; - - if name.eq_ignore_ascii_case("content-length") { - value.trim().parse().ok() - } else { - None - } - }) -} - -struct Destination { - host: String, - port: u16, - path: String, -} - -fn parse_http_destination(target: &str, header_text: &str) -> Option { - if let Some(rest) = target.strip_prefix("http://") { - let (authority, path) = match rest.split_once('/') { - Some((authority, path)) => (authority, format!("/{path}")), - None => (rest, "/".to_string()), - }; - let (host, port) = split_host_port(authority, 80)?; - - return Some(Destination { host, port, path }); - } - - if target.starts_with('/') { - let host_header = header_text.lines().find_map(|line| { - let (name, value) = line.split_once(':')?; - - if name.eq_ignore_ascii_case("host") { - Some(value.trim()) - } else { - None - } - })?; - let (host, port) = split_host_port(host_header, 80)?; - - return Some(Destination { - host, - port, - path: target.to_string(), - }); - } - - None -} - -fn split_host_port(authority: &str, default_port: u16) -> Option<(String, u16)> { - let authority = authority.trim(); - - if authority.is_empty() { - return None; - } - - match authority.rsplit_once(':') { - Some((host, port)) => Some((host.to_string(), port.parse().ok()?)), - None => Some((authority.to_string(), default_port)), - } -} - -fn rewrite_headers(method: &str, path: &str, version: &str, header_text: &str) -> String { - let mut rewritten = String::new(); - rewritten.push_str(method); - rewritten.push(' '); - rewritten.push_str(path); - rewritten.push(' '); - rewritten.push_str(version); - rewritten.push_str("\r\n"); - - for line in header_text.lines().skip(1) { - if line.is_empty() { - continue; - } - - let Some((name, _)) = line.split_once(':') else { - continue; - }; - - if name.eq_ignore_ascii_case("proxy-connection") || name.eq_ignore_ascii_case("connection") - { - continue; - } - - rewritten.push_str(line); - rewritten.push_str("\r\n"); - } - - rewritten.push_str("Connection: close\r\n\r\n"); - rewritten -} - -fn parse_headers(header_text: &str) -> Vec { - header_text - .lines() - .skip(1) - .filter_map(|line| { - let (name, value) = line.split_once(':')?; - - Some(HeaderPair { - name: name.trim().to_string(), - value: value.trim().to_string(), - }) - }) - .collect() -} - -fn content_encoding(header_text: &str) -> Option<&str> { - header_text.lines().find_map(|line| { - let (name, value) = line.split_once(':')?; - if name.trim().eq_ignore_ascii_case("content-encoding") { - Some(value.trim()) - } else { - None - } - }) -} - -fn decompress_body(body: &[u8], encoding: &str) -> Vec { - match encoding.to_ascii_lowercase().as_str() { - "gzip" | "x-gzip" => { - let mut decoded = Vec::new(); - if GzDecoder::new(body).read_to_end(&mut decoded).is_ok() { - decoded - } else { - body.to_vec() - } - } - "deflate" => { - let mut decoded = Vec::new(); - if DeflateDecoder::new(body).read_to_end(&mut decoded).is_ok() { - decoded - } else { - body.to_vec() - } - } - "br" => { - let mut decoded = Vec::new(); - if brotli::BrotliDecompress(&mut std::io::Cursor::new(body), &mut decoded).is_ok() { - decoded - } else { - body.to_vec() - } - } - _ => body.to_vec(), - } -} - -fn parse_response(response: &[u8]) -> (Option, Vec, Vec) { - let Some(header_end) = find_header_end(response) else { - return (None, Vec::new(), response.to_vec()); - }; - - let header_text = String::from_utf8_lossy(&response[..header_end]); - let status = header_text - .lines() - .next() - .and_then(|line| line.split_whitespace().nth(1)) - .and_then(|status| status.parse().ok()); - let headers = parse_headers(&header_text); - let raw_body = response[header_end + 4..].to_vec(); - let decoded_body = if is_chunked(&header_text) { - decode_chunked_body(&raw_body) - } else { - raw_body - }; - let body = if let Some(encoding) = content_encoding(&header_text) { - decompress_body(&decoded_body, encoding) - } else { - decoded_body - }; - - (status, headers, body) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Cursor; - - #[test] - fn decode_chunked_basic() { - let input = b"4\r\ntest\r\n5\r\nhello\r\n0\r\n\r\n"; - assert_eq!(decode_chunked_body(input), b"testhello"); - } - - #[test] - fn decode_chunked_with_extension() { - let input = b"4;ext=val\r\ntest\r\n0\r\n\r\n"; - assert_eq!(decode_chunked_body(input), b"test"); - } - - #[test] - fn decode_chunked_tolerant_truncated() { - // truncated mid-chunk: should not panic - let input = b"4\r\nte"; - let result = decode_chunked_body(input); - assert!(result.len() <= 4); - } - - #[test] - fn decode_chunked_empty() { - assert_eq!(decode_chunked_body(b"0\r\n\r\n"), b""); - } - - #[tokio::test] - async fn read_response_content_length() { - let data = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"; - let mut stream = Cursor::new(data.to_vec()); - let response = read_http_response(&mut stream).await.unwrap(); - assert_eq!(response, data); - } - - #[tokio::test] - async fn read_response_chunked_preserves_raw() { - let data = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\ntest\r\n0\r\n\r\n"; - let mut stream = Cursor::new(data.to_vec()); - let response = read_http_response(&mut stream).await.unwrap(); - assert_eq!(response, data); - } - - #[tokio::test] - async fn read_response_eof_fallback() { - let data = b"HTTP/1.1 200 OK\r\n\r\nbody"; - let mut stream = Cursor::new(data.to_vec()); - let response = read_http_response(&mut stream).await.unwrap(); - assert_eq!(response, data); - } - - #[tokio::test] - async fn read_request_chunked_body() { - let data = b"POST /upload HTTP/1.1\r\nHost: example.test\r\nTransfer-Encoding: chunked\r\n\r\n4\r\ntest\r\n5\r\nhello\r\n0\r\n\r\n"; - let mut stream = Cursor::new(data.to_vec()); - let request = read_http_request(&mut stream).await.unwrap(); - - assert_eq!( - request.headers, - b"POST /upload HTTP/1.1\r\nHost: example.test\r\nTransfer-Encoding: chunked".to_vec() - ); - assert_eq!( - request.body, - b"4\r\ntest\r\n5\r\nhello\r\n0\r\n\r\n".to_vec() - ); - } - - #[tokio::test] - async fn try_read_request_chunked_body() { - let data = b"POST /upload HTTP/1.1\r\nHost: example.test\r\nTransfer-Encoding: chunked\r\n\r\n4\r\ntest\r\n0\r\n\r\n"; - let mut stream = Cursor::new(data.to_vec()); - let request = try_read_http_request(&mut stream).await.unwrap().unwrap(); - - assert_eq!(request.body, b"4\r\ntest\r\n0\r\n\r\n".to_vec()); - } - - #[test] - fn parse_response_decodes_chunked_body() { - let raw = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\ntest\r\n5\r\nhello\r\n0\r\n\r\n"; - let (status, _, body) = parse_response(raw); - assert_eq!(status, Some(200)); - assert_eq!(body, b"testhello"); - } - - #[test] - fn parse_response_content_length_body() { - let raw = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"; - let (status, _, body) = parse_response(raw); - assert_eq!(status, Some(200)); - assert_eq!(body, b"hello"); - } - - #[test] - fn decompress_gzip_body() { - use flate2::write::GzEncoder; - use flate2::Compression; - use std::io::Write; - - let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(b"hello gzip").unwrap(); - let compressed = encoder.finish().unwrap(); - - let result = decompress_body(&compressed, "gzip"); - assert_eq!(result, b"hello gzip"); - } - - #[test] - fn decompress_deflate_body() { - use flate2::write::DeflateEncoder; - use flate2::Compression; - use std::io::Write; - - let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(b"hello deflate").unwrap(); - let compressed = encoder.finish().unwrap(); - - let result = decompress_body(&compressed, "deflate"); - assert_eq!(result, b"hello deflate"); - } - - #[test] - fn decompress_brotli_body() { - let mut compressed = Vec::new(); - brotli::BrotliCompress( - &mut std::io::Cursor::new(b"hello brotli"), - &mut compressed, - &brotli::enc::BrotliEncoderParams::default(), - ) - .unwrap(); - - let result = decompress_body(&compressed, "br"); - assert_eq!(result, b"hello brotli"); - } - - #[test] - fn decompress_invalid_falls_back_to_raw() { - let garbage = b"not valid gzip data"; - let result = decompress_body(garbage, "gzip"); - assert_eq!(result, garbage); - } - - #[test] - fn parse_response_decompresses_gzip() { - use flate2::write::GzEncoder; - use flate2::Compression; - use std::io::Write; - - let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(b"hello").unwrap(); - let compressed = encoder.finish().unwrap(); - - let mut raw = b"HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\n\r\n".to_vec(); - raw.extend_from_slice(&compressed); - - let (status, _, body) = parse_response(&raw); - assert_eq!(status, Some(200)); - assert_eq!(body, b"hello"); - } - - #[test] - fn mitm_error_message_classifies_client_tls_handshake() { - let error = anyhow::anyhow!("client TLS handshake failed for example.test:443"); - let message = https_mitm_error_message("example.test", 443, &error); - - assert!(message.contains("Phase: Client TLS handshake")); - assert!(message.contains("Host: example.test:443")); - assert!(message.contains("Likely cause:")); - assert!(message.contains("Suggested action:")); - assert!(message.contains("Technical error:")); - } - - #[test] - fn mitm_error_message_classifies_upstream_tls_handshake() { - let error = anyhow::anyhow!("upstream TLS handshake failed for example.test:443"); - let message = https_mitm_error_message("example.test", 443, &error); - - assert!(message.contains("Phase: Upstream TLS handshake")); - assert!(message.contains("server certificate")); - } -} diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 72d6ff8..a4a0892 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] anyhow.workspace = true capture = { path = "../capture" } +fault_rules = { path = "../fault_rules" } rusqlite = { version = "0.32", features = ["bundled"] } serde_json.workspace = true uuid.workspace = true diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 483fee4..845194f 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result}; -use capture::{CapturedExchange, HeaderPair}; +use capture::{CapturedExchange, HeaderPair, IgnoreRule}; +use fault_rules::FaultRule; use rusqlite::{params, Connection}; use std::sync::Mutex; use uuid::Uuid; @@ -17,21 +18,73 @@ impl SqliteStore { conn.execute_batch( "CREATE TABLE IF NOT EXISTS exchanges ( - rowid INTEGER PRIMARY KEY AUTOINCREMENT, - id TEXT NOT NULL UNIQUE, - pid INTEGER, - pname TEXT, - method TEXT NOT NULL, - url TEXT NOT NULL, - status INTEGER, - req_headers TEXT NOT NULL, - res_headers TEXT NOT NULL, - req_body BLOB, - res_body BLOB, - duration_ms INTEGER, - error TEXT + rowid INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + pid INTEGER, + pname TEXT, + method TEXT NOT NULL, + url TEXT NOT NULL, + status INTEGER, + req_headers TEXT NOT NULL, + res_headers TEXT NOT NULL, + req_body BLOB, + res_body BLOB, + duration_ms INTEGER, + error TEXT, + timestamp_ms INTEGER );", )?; + // migration for older databases + let _ = conn.execute("ALTER TABLE exchanges ADD COLUMN timestamp_ms INTEGER", []); + let _ = conn.execute( + "ALTER TABLE exchanges ADD COLUMN send_duration_ms INTEGER", + [], + ); + let _ = conn.execute( + "ALTER TABLE exchanges ADD COLUMN wait_duration_ms INTEGER", + [], + ); + let _ = conn.execute( + "ALTER TABLE exchanges ADD COLUMN receive_duration_ms INTEGER", + [], + ); + let _ = conn.execute( + "ALTER TABLE exchanges ADD COLUMN streaming INTEGER NOT NULL DEFAULT 0", + [], + ); + let _ = conn.execute( + "ALTER TABLE exchanges ADD COLUMN response_body_truncated INTEGER NOT NULL DEFAULT 0", + [], + ); + let _ = conn.execute( + "ALTER TABLE exchanges ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0", + [], + ); + + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS fault_rules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + enabled INTEGER NOT NULL, + rule TEXT NOT NULL + );", + )?; + + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS ignored_hosts ( + host TEXT NOT NULL, + method TEXT NOT NULL DEFAULT '' + );", + )?; + // migration for older databases (table used to be host-only) + let _ = conn.execute( + "ALTER TABLE ignored_hosts ADD COLUMN method TEXT NOT NULL DEFAULT ''", + [], + ); + conn.execute_batch( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_ignored_hosts_host_method + ON ignored_hosts (host, method);", + )?; Ok(Self { conn: Mutex::new(conn), @@ -41,11 +94,30 @@ impl SqliteStore { pub fn insert_exchange(&self, exchange: &CapturedExchange) -> Result<()> { let conn = self.conn.lock().expect("storage lock poisoned"); conn.execute( - "INSERT OR IGNORE INTO exchanges - (id, pid, pname, method, url, status, req_headers, res_headers, req_body, res_body, duration_ms, error) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + "INSERT INTO exchanges + (id, pinned, pid, pname, method, url, status, req_headers, res_headers, req_body, res_body, duration_ms, error, timestamp_ms, send_duration_ms, wait_duration_ms, receive_duration_ms, streaming, response_body_truncated) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19) + ON CONFLICT(id) DO UPDATE SET + pid = ?3, + pname = ?4, + method = ?5, + url = ?6, + status = ?7, + req_headers = ?8, + res_headers = ?9, + req_body = ?10, + res_body = ?11, + duration_ms = ?12, + error = ?13, + timestamp_ms = ?14, + send_duration_ms = ?15, + wait_duration_ms = ?16, + receive_duration_ms = ?17, + streaming = ?18, + response_body_truncated = ?19", params![ exchange.id.to_string(), + exchange.pinned, exchange.process_pid, exchange.process_name, exchange.method, @@ -57,10 +129,16 @@ impl SqliteStore { exchange.response_body, exchange.duration_ms, exchange.error, + exchange.timestamp_ms, + exchange.send_duration_ms, + exchange.wait_duration_ms, + exchange.receive_duration_ms, + exchange.streaming, + exchange.response_body_truncated, ], )?; conn.execute( - "DELETE FROM exchanges WHERE rowid NOT IN (SELECT rowid FROM exchanges ORDER BY rowid DESC LIMIT ?1)", + "DELETE FROM exchanges WHERE pinned = 0 AND rowid NOT IN (SELECT rowid FROM exchanges WHERE pinned = 0 ORDER BY rowid DESC LIMIT ?1)", [MAX_ROWS], )?; Ok(()) @@ -69,37 +147,65 @@ impl SqliteStore { pub fn list_exchanges(&self) -> Result> { let conn = self.conn.lock().expect("storage lock poisoned"); let mut stmt = conn.prepare( - "SELECT id, pid, pname, method, url, status, req_headers, res_headers, req_body, res_body, duration_ms, error + "SELECT id, pinned, pid, pname, method, url, status, req_headers, res_headers, req_body, res_body, duration_ms, error, timestamp_ms, send_duration_ms, wait_duration_ms, receive_duration_ms, streaming, response_body_truncated FROM exchanges ORDER BY rowid ASC", )?; let rows = stmt.query_map([], |row| { - let req_headers_json: String = row.get(6)?; - let res_headers_json: String = row.get(7)?; + let req_headers_json: String = row.get(7)?; + let res_headers_json: String = row.get(8)?; Ok(( row.get::<_, String>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, String>(3)?, + row.get::<_, bool>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, row.get::<_, String>(4)?, - row.get::<_, Option>(5)?, + row.get::<_, String>(5)?, + row.get::<_, Option>(6)?, req_headers_json, res_headers_json, - row.get::<_, Option>>(8)?, row.get::<_, Option>>(9)?, - row.get::<_, Option>(10)?, - row.get::<_, Option>(11)?, + row.get::<_, Option>>(10)?, + row.get::<_, Option>(11)?, + row.get::<_, Option>(12)?, + row.get::<_, Option>(13)?, + row.get::<_, Option>(14)?, + row.get::<_, Option>(15)?, + row.get::<_, Option>(16)?, + row.get::<_, bool>(17)?, + row.get::<_, bool>(18)?, )) })?; let mut exchanges = Vec::new(); for row in rows { - let (id, pid, pname, method, url, status, req_h, res_h, req_b, res_b, dur, err) = row?; + let ( + id, + pinned, + pid, + pname, + method, + url, + status, + req_h, + res_h, + req_b, + res_b, + dur, + err, + ts, + send_dur, + wait_dur, + recv_dur, + streaming, + response_body_truncated, + ) = row?; let request_headers: Vec = serde_json::from_str(&req_h).unwrap_or_default(); let response_headers: Vec = serde_json::from_str(&res_h).unwrap_or_default(); exchanges.push(CapturedExchange { id: id.parse::().unwrap_or_else(|_| Uuid::new_v4()), + pinned, process_pid: pid, process_name: pname, method, @@ -110,7 +216,13 @@ impl SqliteStore { request_body: req_b, response_body: res_b, duration_ms: dur, + send_duration_ms: send_dur, + wait_duration_ms: wait_dur, + receive_duration_ms: recv_dur, error: err, + timestamp_ms: ts.unwrap_or(0), + streaming, + response_body_truncated, }); } Ok(exchanges) @@ -118,7 +230,191 @@ impl SqliteStore { pub fn clear(&self) -> Result<()> { let conn = self.conn.lock().expect("storage lock poisoned"); - conn.execute("DELETE FROM exchanges", [])?; + conn.execute("DELETE FROM exchanges WHERE pinned = 0", [])?; + Ok(()) + } + + pub fn set_exchange_pinned(&self, id: Uuid, pinned: bool) -> Result<()> { + let conn = self.conn.lock().expect("storage lock poisoned"); + conn.execute( + "UPDATE exchanges SET pinned = ?2 WHERE id = ?1", + params![id.to_string(), pinned], + )?; + Ok(()) + } + + pub fn delete_exchange(&self, id: Uuid) -> Result<()> { + let conn = self.conn.lock().expect("storage lock poisoned"); + conn.execute("DELETE FROM exchanges WHERE id = ?1", [id.to_string()])?; + Ok(()) + } + + /// Inserts a new fault rule or replaces one with the same `id`. + pub fn upsert_fault_rule(&self, rule: &FaultRule) -> Result<()> { + let conn = self.conn.lock().expect("storage lock poisoned"); + conn.execute( + "INSERT INTO fault_rules (id, name, enabled, rule) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(id) DO UPDATE SET name = ?2, enabled = ?3, rule = ?4", + params![ + rule.id.to_string(), + rule.name, + rule.enabled, + serde_json::to_string(rule)?, + ], + )?; + Ok(()) + } + + pub fn delete_fault_rule(&self, id: Uuid) -> Result<()> { + let conn = self.conn.lock().expect("storage lock poisoned"); + conn.execute("DELETE FROM fault_rules WHERE id = ?1", [id.to_string()])?; + Ok(()) + } + + pub fn list_fault_rules(&self) -> Result> { + let conn = self.conn.lock().expect("storage lock poisoned"); + let mut stmt = conn.prepare("SELECT rule FROM fault_rules ORDER BY rowid ASC")?; + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; + + let mut rules = Vec::new(); + for row in rows { + let json = row?; + rules.push(serde_json::from_str(&json)?); + } + Ok(rules) + } + + pub fn list_ignored_hosts(&self) -> Result> { + let conn = self.conn.lock().expect("storage lock poisoned"); + let mut stmt = + conn.prepare("SELECT host, method FROM ignored_hosts ORDER BY host ASC, method ASC")?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })?; + let mut rules = Vec::new(); + for row in rows { + let (host, method) = row?; + rules.push(IgnoreRule { + host, + method: if method.is_empty() { + None + } else { + Some(method) + }, + }); + } + Ok(rules) + } + + pub fn add_ignored_host(&self, host: &str, method: Option<&str>) -> Result<()> { + let conn = self.conn.lock().expect("storage lock poisoned"); + conn.execute( + "INSERT OR IGNORE INTO ignored_hosts (host, method) VALUES (?1, ?2)", + params![host, method.unwrap_or("")], + )?; Ok(()) } + + pub fn remove_ignored_host(&self, host: &str, method: Option<&str>) -> Result<()> { + let conn = self.conn.lock().expect("storage lock poisoned"); + conn.execute( + "DELETE FROM ignored_hosts WHERE host = ?1 AND method = ?2", + params![host, method.unwrap_or("")], + )?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fault_rules::{FaultAction, RuleMatch, RuleTarget}; + + fn sample_rule(name: &str) -> FaultRule { + FaultRule { + id: Uuid::new_v4(), + name: name.to_string(), + enabled: true, + probability: 1.0, + target: RuleTarget::Response, + rule_match: RuleMatch { + host: Some("api.example.com".into()), + ..Default::default() + }, + action: FaultAction::Delay { ms: 500 }, + } + } + + fn sample_exchange(id: Uuid, body: &[u8], streaming: bool) -> CapturedExchange { + CapturedExchange { + id, + pinned: false, + process_pid: None, + process_name: Some("test".into()), + method: "GET".into(), + url: "https://example.com/events".into(), + status: Some(200), + request_headers: vec![HeaderPair { + name: "accept".into(), + value: "text/event-stream".into(), + }], + response_headers: vec![HeaderPair { + name: "content-type".into(), + value: "text/event-stream".into(), + }], + request_body: None, + response_body: Some(body.to_vec()), + duration_ms: None, + send_duration_ms: None, + wait_duration_ms: None, + receive_duration_ms: None, + error: None, + timestamp_ms: 42, + streaming, + response_body_truncated: false, + } + } + + #[test] + fn upserts_lists_and_deletes_fault_rules() { + let store = SqliteStore::open(std::path::Path::new(":memory:")).unwrap(); + let rule = sample_rule("slow api"); + + store.upsert_fault_rule(&rule).unwrap(); + let listed = store.list_fault_rules().unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].name, "slow api"); + + let mut updated = rule.clone(); + updated.name = "slower api".into(); + store.upsert_fault_rule(&updated).unwrap(); + let listed = store.list_fault_rules().unwrap(); + assert_eq!(listed.len(), 1, "same id must replace, not duplicate"); + assert_eq!(listed[0].name, "slower api"); + + store.delete_fault_rule(rule.id).unwrap(); + assert!(store.list_fault_rules().unwrap().is_empty()); + } + + #[test] + fn upserts_exchange_by_id() { + let store = SqliteStore::open(std::path::Path::new(":memory:")).unwrap(); + let id = Uuid::new_v4(); + + store + .insert_exchange(&sample_exchange(id, b"data: one\n\n", true)) + .unwrap(); + store + .insert_exchange(&sample_exchange(id, b"data: one\n\ndata: two\n\n", true)) + .unwrap(); + + let listed = store.list_exchanges().unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, id); + assert_eq!( + listed[0].response_body.as_deref(), + Some(&b"data: one\n\ndata: two\n\n"[..]) + ); + assert!(listed[0].streaming); + } } diff --git a/docs/live-streaming-bodies.md b/docs/live-streaming-bodies.md new file mode 100644 index 0000000..2e4d011 --- /dev/null +++ b/docs/live-streaming-bodies.md @@ -0,0 +1,127 @@ +# Faire vivre les corps en streaming (SSE, chunked long-lived) + +## Contexte / constat + +Aujourd'hui, un flow n'est envoyé qu'une seule fois, en entier, quand mitmproxy +a fini de le bufferiser (`bridge_addon.py`'s `response()` hook, qui appelle +`_send(self._serialize(flow))`). Pour une réponse `text/event-stream` (SSE) ou +tout flux long-lived : + +- Si le flux finit par se fermer, l'exchange n'apparait dans ProcTap qu'*après + coup*, corps entier concaténé (pas d'affichage évènement par évènement en + direct). +- Si le flux ne se ferme jamais tant que l'app tourne, l'exchange n'apparait + **jamais**. +- Aucune limite de taille n'est appliquée au corps capturé : un stream long + avant fermeture peut consommer beaucoup de mémoire (déjà noté comme lacune + connue dans `README.md`, roadmap : "Improve handling for large or streaming + bodies with safe memory limits"). + +Le but de ce ticket : permettre d'observer un flow SSE (ou tout stream) au fil +de l'eau dans l'UI desktop, pas seulement une fois terminé. + +## Pourquoi ce n'est pas trivial + +Toute la chaine est conçue pour "un message JSON immuable par flow terminé, +une seule fois" : + +- Socket bridge (Python <-> Rust) : une ligne JSON = un flow complet. +- `mitm_engine::WireExchange` : struct plate, pas de notion de "flow en + cours", pas de champ id transmis par Python. +- `capture::push_capture` : `captures.push(exchange)` uniquement, jamais de + update in-place d'un exchange existant. +- `storage::insert_exchange` : `INSERT OR IGNORE`, pas de chemin `UPDATE`. +- Frontend : poll (`setInterval` 1.5s dans `App.tsx`) qui refetch un snapshot + complet — ça, en revanche, marche déjà "gratuitement" si le backend fait de + l'upsert par id (pas besoin de websocket/SSE côté UI). + +## Ce qu'il faudrait changer, couche par couche + +### 1. `crates/mitm_engine/src/bridge_addon.py` + +- Détecter les réponses à streamer (typiquement dans un hook + `responseheaders()` : `content-type: text/event-stream`, ou + `transfer-encoding: chunked` sans `content-length`) et activer + `flow.response.stream = ` pour ces flows. +- Le callback reçoit les chunks bruts au fur et à mesure — c'est le point + d'accroche pour émettre des messages incrémentaux sur le socket bridge. +- **Piège** : une fois `stream` activé sur un flow, `flow.response.raw_content` + n'est plus disponible dans le hook `response()` final (le corps n'est jamais + bufferisé côté mitmproxy). Ça casse en l'état : + - `_serialize()` (lit `response.raw_content` pour `response_body_b64`) ; + - l'action de fault injection `throttle` (lit aussi `raw_content`, ligne + ~121) — il faudra soit l'exclure pour les flows streamés, soit + l'implémenter différemment (délai par chunk plutôt que délai global sur + tout le corps). + +### 2. Protocole du socket bridge + +- Remplacer "un objet JSON = un flow terminé" par une enveloppe à plusieurs + types de messages, par exemple : `started` (id stable + headers connus dès + le début), `chunk` (id, bytes/b64, seq), `done` (id, timing final, status si + connu tardivement). +- Il faut un id stable partagé entre Python et Rust dès le *début* du flow. + mitmproxy expose déjà `flow.id`, mais il n'est utilisé nulle part + aujourd'hui — `mitm_engine` ne génère un `Uuid` qu'à la réception de l'objet + complet. Il faudra probablement réutiliser `flow.id` (ou le faire + correspondre à un `Uuid` généré côté Rust et renvoyé à l'addon via le canal + de règles déjà bidirectionnel). + +### 3. `crates/mitm_engine/src/lib.rs` + +- `WireExchange` (struct unique, tout-ou-rien) devient un enum d'évènements à + désérialiser (`Started` / `Chunk` / `Done`). +- La boucle `run_with_fault_rules` doit distinguer "nouvel exchange" (push) + de "chunk reçu pour un exchange existant" (update), au lieu d'appeler + `on_exchange` une fois par ligne reçue sans distinction. + +### 4. `crates/capture` (store en mémoire) + +- `push_capture` (actuellement `captures.push(exchange)` seulement) a besoin + d'un pendant `push_or_update_capture(id, patch)` qui retrouve l'entrée + existante par id et append au `response_body`, avec un flag genre + `streaming: bool` pour que l'UI sache que ce n'est pas terminé. +- Revoir la limite "500 exchanges" (troncature par `remove(0)`) et ajouter une + limite de taille de corps pour les flows en streaming (troncature avec + indicateur "tronqué" plutôt que croissance illimitée) — c'est l'item + roadmap déjà identifié dans `README.md`. + +### 5. `crates/storage` (SQLite) + +- `insert_exchange` fait `INSERT OR IGNORE` : pas de chemin d'update. + Deux options : + - écrire une seule fois à la fin (le live reste un phénomène purement en + mémoire, jamais persisté avant complétion) — plus simple, perd l'historique + d'un stream si l'app crashe en cours de route ; + - ajouter un `UPDATE ... SET response_body = ?, status = ? WHERE id = ?` + appelé à chaque chunk (ou périodiquement, pas à chaque évènement SSE + individuel, pour limiter le coût I/O). + +### 6. Frontend (`apps/desktop/src/App.tsx`) + +- Le modèle de poll existant (toutes les 1.5s) fonctionne déjà tel quel *si* + le backend fait de l'upsert par id : chaque poll renverrait juste un corps + plus long pour la même entrée, sans rien changer côté React/Tauri command. +- Seul ajout utile : un badge "live/streaming" tant que `streaming: true`, + pour distinguer visuellement un exchange en cours d'un exchange figé. + `BodyViewer` (ligne ~1488) affiche déjà le corps en texte brut, ça marche + tel quel pour du contenu qui grandit. + +## Résumé + +Le point dur n'est ni l'UI (elle suit "gratuitement" via le poll existant) ni +vraiment l'API mitmproxy (le mode streaming existe déjà), c'est de faire muter +tout le pipeline vers un modèle "id stable + upsert incrémental" de bout en +bout : `bridge_addon.py` -> socket bridge -> `mitm_engine` -> `capture` store +-> SQLite. Il faut aussi décider une politique de troncature mémoire pour les +flows qui ne se ferment jamais, sinon on rouvre le point déjà noté en +Known Limitations / roadmap du README. + +## Portée suggérée pour un premier incrément + +Ne pas tout faire d'un coup : possible V1 raisonnable = SSE uniquement (pas +tout chunked générique), pas de persistance incrémentale en base (uniquement +en mémoire tant que le flow est en cours, écriture SQLite finale une fois +`done`), troncature dure du corps affiché en direct (ex: garder les N derniers +Ko). Ça couvre le cas d'usage principal (voir les events SSE arriver) sans +toucher au throttle/fault-injection ni à `storage`. diff --git a/docs/mvp.md b/docs/mvp.md index 4d6f34f..18b83f6 100644 --- a/docs/mvp.md +++ b/docs/mvp.md @@ -1,41 +1,55 @@ -# OpenIntercept MVP +# ProcTap MVP ## Goal -Build a local desktop application that lets a developer intercept and inspect HTTP and HTTPS traffic through a local read-only proxy. +Build a local desktop application that lets a developer capture and inspect HTTP and HTTPS traffic from a chosen process, without modifying that process's configuration. The MVP is read-only. It must never modify, block, replay, mock, or rewrite traffic. -The primary product goal is reliable local HTTPS interception with the most automatic certificate workflow possible. JVM inspection is a key follow-up workflow, not the first identity of the product. +The primary product goal is reliable per-process capture with the most automatic HTTPS trust workflow possible. JVM trust automation is a key follow-up workflow, not the first identity of the product. + +## Architecture + +ProcTap does not implement its own MITM proxy engine. It runs [mitmproxy](https://mitmproxy.org/)'s `mitmdump` as an external subprocess in `--mode local:` mode, or `--mode local:,` when several picker captures are active, which redirects only matching process traffic at the OS level (WinDivert on Windows, a native path on Linux) — no proxy configuration, environment variables, or trust store changes are required on the target application itself beyond CA trust. + +``` +apps/desktop (Tauri + React) + | + | Tauri commands: list_processes, start_capture, stop_capture, ca_status + v +crates/mitm_engine + - locates mitmdump on PATH (or PROCTAP_MITMDUMP) + - spawns `mitmdump --mode local:[,...] --scripts bridge_addon.py --set confdir=/mitm-ca` + - runs a local loopback TCP listener that the addon connects to + - maps incoming flow JSON -> capture::CapturedExchange + - installs mitmdump's own CA into the OS trust store, and into JDK truststores for JVM targets + | + v +bridge_addon.py (bundled with mitm_engine) + - a mitmproxy addon using the public response/error hooks + - serializes each flow as one JSON line per flow over the loopback socket + +crates/capture - CapturedExchange model + the in-memory capture store/sink +crates/storage - SQLite persistence for captures +crates/processes - lists OS processes for the picker, tags JVM processes for trust automation +``` ## User Flow -1. Start OpenIntercept. -2. OpenIntercept starts or reports its local proxy on `127.0.0.1:8877`. -3. OpenIntercept ensures a local CA is available for HTTPS MITM. -4. The user routes local application traffic through the proxy. -5. The user completes the minimum required trust step for HTTPS. -6. Captured traffic appears in the UI. - -## Secondary JVM Flow - -1. OpenIntercept lists running Java processes. -2. The user selects a process and clicks `Intercept selected process`. -3. OpenIntercept attaches a Java agent to that JVM. -4. The Java agent configures proxy properties for that JVM. -5. The JVM traffic is routed through the proxy when the client stack cooperates. +1. Start ProcTap. +2. ProcTap lists running processes; the user picks one (by name) to capture. +3. ProcTap spawns `mitmdump` targeting that process and waits for it to write its CA into ProcTap's confdir. +4. ProcTap installs that CA into the current user's OS trust store automatically, and into every discovered JDK truststore if the target looks like a JVM process. +5. The user reproduces traffic from the target process; captured exchanges stream into the UI live. ## MVP Scope - Desktop app with Tauri and React. - Rust backend commands exposed to the UI. -- Local proxy boundary. -- HTTPS MITM with local CA and host certificates. -- Certificate/trust setup helpers for local development. -- Capture model. -- SQLite storage boundary. -- Java process discovery. -- Java agent and attach helper design. +- `crates/mitm_engine`: spawns and drives `mitmdump` as the capture engine. +- Automatic CA trust installation into the OS trust store and JDK truststores. +- Capture model and SQLite storage boundary. +- Process discovery, with JVM tagging for trust automation. ## Out Of Scope @@ -49,45 +63,26 @@ The primary product goal is reliable local HTTPS interception with the most auto ## Milestones -### 1. Desktop Shell - -- Tauri application launches. -- UI shows proxy status. -- UI lists Java processes. +### 1. Rename skeleton -### 2. HTTPS Trust Setup +Renamed from OpenIntercept to ProcTap across the codebase, docs, and packaging metadata. -- Generate a local OpenIntercept CA. -- Generate host certificates dynamically. -- Make certificate reuse and trust setup predictable. -- Keep the trust flow as automatic as platform constraints allow. +### 2. Engine spike -### 3. Proxy HTTP/HTTPS +`crates/mitm_engine` and `bridge_addon.py`: detect mitmproxy, spawn `mitmdump --mode local:`, receive flow JSON over a local socket. -- Start local HTTP proxy. -- Capture basic HTTP requests and responses. -- Terminate HTTPS `CONNECT` and inspect HTTPS requests and responses. -- Stream captured exchanges to UI. +### 3. Wire it up -### 4. JVM Workflow +Tauri commands (`list_processes`, `start_capture`, `stop_capture`) and a process-picker UI, feeding the existing `CapturedExchange` -> SQLite pipeline. -- Build `java/attach-helper`. -- Build `java/agent`. -- Attach to a selected PID. -- Inject HTTP and HTTPS proxy properties. +### 4. Trust automation -Current implementation terminates `CONNECT` with a first-pass HTTPS MITM flow. It generates a hostname certificate, completes TLS with the client, opens TLS to the upstream server, forwards one HTTP request, captures it, and closes the tunnel with `Connection: close`. +On `start_capture`, install mitmdump's own CA (from its confdir) into the OS trust store, and into every discovered JDK truststore for JVM targets, automatically. -The local OpenIntercept CA is generated at desktop startup and stored in the user's local data directory. Host certificates are generated dynamically and cached under the app certificate directory. The product should reduce manual trust steps as much as possible, but some clients and platforms still require explicit trust configuration before HTTPS inspection succeeds. +### 5. Cleanup -### 5. JVM TLS Trust - -- Load OpenIntercept CA into the selected JVM. -- Set default SSL context when possible. -- Document limitations for clients with custom TLS stacks or certificate pinning. +Removed the hand-rolled in-process MITM proxy (`crates/proxy`, `crates/certs`), the Java attach agent/helper workflow, and the request/response scripting and environments UI — all superseded by mitmproxy driving capture at the OS level. Renamed `crates/java_processes` to `crates/processes`. ## Known JVM Limitations -Attaching to an already running JVM cannot guarantee interception for every HTTP client. - -It should work best for clients that respect JVM proxy properties and default TLS trust. It may require restart or deeper instrumentation for clients that pre-build their own connection pools, proxy selectors, SSL contexts, or pin certificates. +Trust automation imports mitmdump's CA into every JDK truststore this machine can discover. It does not guarantee interception for every HTTP client: clients that pin certificates, use a custom `SSLContext`, or build their own connection pools before the CA import completes may still fail to validate the ProcTap-issued certificate. diff --git a/docs/spring-boot-proxy.md b/docs/spring-boot-proxy.md index 6058003..a9857b1 100644 --- a/docs/spring-boot-proxy.md +++ b/docs/spring-boot-proxy.md @@ -1,35 +1,25 @@ -# Spring Boot Proxy Configuration +# Spring Boot / JVM Capture -This mode does not use Java attach. You explicitly start the Spring Boot application with JVM proxy and truststore options that point to OpenIntercept. - -This is currently the most reliable JVM workflow because it makes both proxy routing and HTTPS trust explicit. +ProcTap captures a target process's traffic at the OS level via `mitmdump --mode local:`. For a Spring Boot app (or any JVM process), this means you do **not** need to configure JVM proxy properties, a truststore, or `JAVA_TOOL_OPTIONS` — start the app normally, then pick its process name (typically `java` or `java.exe`) in ProcTap's capture picker. ## Prerequisites -1. Start OpenIntercept once so it generates its local CA. -2. Create a Java truststore from the OpenIntercept CA: - -```powershell -./scripts/create-java-truststore.ps1 -``` - -Default output: +- [mitmproxy](https://mitmproxy.org/) installed, with `mitmdump` on `PATH` (or set `PROCTAP_MITMDUMP` to its full path). +- A JDK, if you want ProcTap's trust automation to import mitmproxy's CA into that JDK's truststore. -```txt -java/build/openintercept-truststore.p12 -``` +## Trust Automation -Default password: +When you start a capture targeting a process ProcTap recognizes as a JVM (name contains `java`/`javaw`), ProcTap automatically: -```txt -changeit -``` +1. Waits for `mitmdump` to write its CA into ProcTap's local confdir. +2. Installs that CA into the current user's OS trust store. +3. Runs `scripts/manage-jdk-truststore.ps1`/`.sh` in non-interactive, "all JDKs" mode, importing the CA into every JDK truststore this machine can discover. -## Optional: Trust OpenIntercept Globally For One JDK +No manual `keytool` step or explicit `-Djavax.net.ssl.trustStore` flag is needed for the common case. -If you use a dedicated local development JDK, you can import the OpenIntercept CA directly into that JDK's `cacerts` truststore. This makes every app launched with that JDK trust OpenIntercept certificates. +### Manually managing the JDK truststore -Use the interactive manager: +If you want to inspect or control this yourself — for example to import into a truststore before starting a capture, or to remove ProcTap's CA later — use the same script ProcTap calls internally: ```powershell ./scripts/manage-jdk-truststore.ps1 @@ -47,135 +37,34 @@ Import into a specific JDK: ./scripts/manage-jdk-truststore.ps1 -Action import -JavaHome "C:\path\to\jdk" ``` -Check status: +Import into every discovered JDK non-interactively (what ProcTap runs automatically): ```powershell -./scripts/manage-jdk-truststore.ps1 -Action status -JavaHome "C:\path\to\jdk" +./scripts/manage-jdk-truststore.ps1 -Action import -All ``` Remove later: ```powershell -./scripts/manage-jdk-truststore.ps1 -Action remove -JavaHome "C:\path\to\jdk" -``` - -This is convenient, but it is global for that JDK. Use it mainly for a local dev JDK. - -## Why This Path Matters - -OpenIntercept is primarily a local HTTPS interception proxy. The explicit Spring Boot startup flow is important because it gives a reliable way to route JVM traffic through the proxy while keeping HTTPS trust understandable and repeatable. - -## Generic JVM Configuration - -Use these JVM options for any Spring Boot application that uses JVM proxy and trust settings: - -```txt --Dhttp.proxyHost=127.0.0.1 --Dhttp.proxyPort=8877 --Dhttps.proxyHost=127.0.0.1 --Dhttps.proxyPort=8877 --Djava.net.useSystemProxies=false --Djavax.net.ssl.trustStore=/absolute/path/to/openintercept-truststore.p12 --Djavax.net.ssl.trustStorePassword=changeit --Djavax.net.ssl.trustStoreType=PKCS12 -``` - -PowerShell example with `JAVA_TOOL_OPTIONS`: - -```powershell -$env:JAVA_TOOL_OPTIONS="-Dhttp.proxyHost=127.0.0.1 -Dhttp.proxyPort=8877 -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=8877 -Djava.net.useSystemProxies=false -Djavax.net.ssl.trustStore=$PWD/java/build/openintercept-truststore.p12 -Djavax.net.ssl.trustStorePassword=changeit -Djavax.net.ssl.trustStoreType=PKCS12" +./scripts/manage-jdk-truststore.ps1 -Action remove -All ``` -Then start the app normally: - -```powershell -./mvnw spring-boot:run -``` - -or: - -```powershell -./gradlew bootRun -``` - -or: - -```powershell -java -jar target/my-app.jar -``` - -## Maven Spring Boot Plugin - -```powershell -./mvnw spring-boot:run ` - -Dspring-boot.run.jvmArguments="-Dhttp.proxyHost=127.0.0.1 -Dhttp.proxyPort=8877 -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=8877 -Djava.net.useSystemProxies=false -Djavax.net.ssl.trustStore=C:/absolute/path/openintercept-truststore.p12 -Djavax.net.ssl.trustStorePassword=changeit -Djavax.net.ssl.trustStoreType=PKCS12" -``` - -## Gradle Boot Run - -```powershell -./gradlew bootRun --args='' -``` - -Add this to the app's `build.gradle` for repeatable local debugging: - -```groovy -tasks.named('bootRun') { - jvmArgs = [ - '-Dhttp.proxyHost=127.0.0.1', - '-Dhttp.proxyPort=8877', - '-Dhttps.proxyHost=127.0.0.1', - '-Dhttps.proxyPort=8877', - '-Djava.net.useSystemProxies=false', - '-Djavax.net.ssl.trustStore=C:/absolute/path/openintercept-truststore.p12', - '-Djavax.net.ssl.trustStorePassword=changeit', - '-Djavax.net.ssl.trustStoreType=PKCS12' - ] -} -``` +On Linux/macOS, use `./scripts/manage-jdk-truststore.sh` with the equivalent `--list`, `-a import -j `, `--all`, and `-a remove --all` flags. ## Client-Specific Notes -The JVM properties work best for clients that use JVM defaults. - -They generally work with: - -- `RestTemplate` backed by `SimpleClientHttpRequestFactory`; -- Java `HttpClient` when configured with default proxy selector; -- libraries that honor `http.proxyHost` and `https.proxyHost`. - -They may not be enough for clients with explicit proxy or TLS configuration: +Because capture happens at the OS network level rather than through JVM proxy properties, it works for any HTTP client the JVM uses — `RestTemplate`, Java `HttpClient`, Apache HttpClient, OkHttp, Reactor Netty/WebClient — without per-client configuration. -- Apache HttpClient with a custom route planner; -- OkHttp with a custom `Proxy` or `SSLSocketFactory`; -- Reactor Netty/WebClient with custom `HttpClient`; -- any client using certificate pinning. +The one thing that still matters is **HTTPS trust**: a client will only decrypt correctly if it trusts ProcTap's CA. Trust automation handles the common case (the JDK's own truststore), but it may not be enough for clients that: -For those clients, configure the proxy explicitly in the client builder and ensure it uses the truststore or default JVM `SSLContext`. - -## WebClient / Reactor Netty Example - -If your Spring Boot app uses WebClient with Reactor Netty, configure the proxy in code: - -```java -@Bean -WebClient webClient(WebClient.Builder builder) { - HttpClient httpClient = HttpClient.create() - .proxy(proxy -> proxy - .type(ProxyProvider.Proxy.HTTP) - .host("127.0.0.1") - .port(8877)); - - return builder - .clientConnector(new ReactorClientHttpConnector(httpClient)) - .build(); -} -``` +- pin certificates; +- build a custom `SSLContext` or `TrustManager` before the CA import completes; +- were already running (and had already loaded their TLS state) before you started the capture — restart the app after the CA is imported if HTTPS traffic doesn't decode. -Keep the JVM truststore options enabled for HTTPS trust. +## When the JVM can't be picked by process (containers, remote hosts) -## Current OpenIntercept Limitation +The process picker only works for a JVM running on the same machine as ProcTap, reachable by name/PID. If your Spring Boot app runs in a container or on a remote host, use ProcTap's **Explicit proxy** toggle instead: start it with a port, then configure that JVM's proxy properties to point at ProcTap's machine (`-Dhttp.proxyHost= -Dhttp.proxyPort= -Dhttps.proxyHost= -Dhttps.proxyPort=`) and import ProcTap's CA into that JVM's truststore manually (see `manage-jdk-truststore.ps1`/`.sh` above — point `-JavaHome`/`-j` at the container/remote JDK's location, or copy the CA cert there and run `keytool` directly if it's not reachable from this machine). This is the one case where ProcTap still needs explicit client-side configuration, unlike the picker's zero-config OS-level redirection; traffic captured this way shows up as `External client` rather than attributed to a process, since ProcTap has no visibility into what's actually connecting. -OpenIntercept currently supports a first-pass HTTPS MITM flow with one request per tunnel and `Connection: close`. This is enough for validating HTTPS inspection, but keep-alive and HTTP/2 are not implemented yet. +## Current ProcTap Limitations -Java attach remains a secondary workflow. It can help route JVM traffic into the proxy, but the explicit proxy plus truststore startup path is still the most dependable option today. +HTTP/2 is not supported yet; capture currently covers HTTP/1.1 flows. Keep-alive across multiple requests in the same connection is supported by mitmproxy itself. diff --git a/java/README.md b/java/README.md deleted file mode 100644 index 37d27d4..0000000 --- a/java/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# Java Components - -This directory contains the Java-side pieces for JVM-specific interception workflows on top of the core OpenIntercept proxy. - -The primary product focus remains the HTTPS proxy itself. These Java components exist to make JVM traffic easier to route through that proxy when needed. - -## Planned Components - -- `agent`: Java agent loaded into a running JVM with `agentmain`. -- `attach-helper`: Small Java CLI that uses `com.sun.tools.attach.VirtualMachine` to load the agent into a target PID. - -## Initial Agent Responsibilities - -- Set `http.proxyHost` and `http.proxyPort`. -- Set `https.proxyHost` and `https.proxyPort`. -- Disable system proxy auto-detection for deterministic behavior. -- Install OpenIntercept CA into the default TLS context when possible. - -TLS trust installation is not implemented yet. The current agent only injects JVM proxy properties, so explicit JVM startup with proxy and trust settings remains the most reliable path. - -## Build - -From the repository root: - -```powershell -./scripts/build-java.ps1 -``` - -This produces: - -- `java/build/openintercept-agent.jar` -- `java/build/openintercept-attach-helper.jar` diff --git a/java/agent/MANIFEST.MF b/java/agent/MANIFEST.MF deleted file mode 100644 index 0b1bd58..0000000 --- a/java/agent/MANIFEST.MF +++ /dev/null @@ -1,5 +0,0 @@ -Manifest-Version: 1.0 -Agent-Class: dev.openintercept.agent.OpenInterceptAgent -Premain-Class: dev.openintercept.agent.OpenInterceptAgent -Can-Redefine-Classes: false -Can-Retransform-Classes: false diff --git a/java/agent/agent.iml b/java/agent/agent.iml deleted file mode 100644 index c90834f..0000000 --- a/java/agent/agent.iml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/java/agent/src/dev/openintercept/agent/OpenInterceptAgent.java b/java/agent/src/dev/openintercept/agent/OpenInterceptAgent.java deleted file mode 100644 index 0215b30..0000000 --- a/java/agent/src/dev/openintercept/agent/OpenInterceptAgent.java +++ /dev/null @@ -1,143 +0,0 @@ -package dev.openintercept.agent; - -import java.lang.instrument.Instrumentation; -import java.util.HashMap; -import java.util.Map; - -public final class OpenInterceptAgent { - private OpenInterceptAgent() { - } - - public static void premain(String args, Instrumentation instrumentation) { - configure(args); - } - - public static void agentmain(String args, Instrumentation instrumentation) { - configure(args); - } - - private static void configure(String args) { - Map parsedArgs = parseArgs(args); - String proxyHost = parsedArgs.getOrDefault("proxyHost", "127.0.0.1"); - String proxyPort = parsedArgs.getOrDefault("proxyPort", "8877"); - String caCertPath = parsedArgs.get("caCertPath"); - - System.setProperty("http.proxyHost", proxyHost); - System.setProperty("http.proxyPort", proxyPort); - System.setProperty("https.proxyHost", proxyHost); - System.setProperty("https.proxyPort", proxyPort); - System.setProperty("java.net.useSystemProxies", "false"); - - System.out.println("OpenIntercept agent configured proxy " + proxyHost + ":" + proxyPort); - - if (caCertPath != null && !caCertPath.isBlank()) { - try { - injectCaCertificate(caCertPath); - System.out.println("OpenIntercept agent injected CA certificate from " + caCertPath); - } catch (Exception e) { - System.err.println("OpenIntercept agent failed to inject CA certificate: " + e.getMessage()); - } - } - } - - private static void injectCaCertificate(String certPath) throws Exception { - byte[] certBytes = java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(certPath)); - String pem = new String(certBytes, java.nio.charset.StandardCharsets.UTF_8); - String base64 = pem - .replaceAll("-----BEGIN CERTIFICATE-----", "") - .replaceAll("-----END CERTIFICATE-----", "") - .replaceAll("\\s", ""); - byte[] derBytes = java.util.Base64.getDecoder().decode(base64); - - java.security.cert.CertificateFactory cf = java.security.cert.CertificateFactory.getInstance("X.509"); - java.security.cert.X509Certificate caCert = (java.security.cert.X509Certificate) - cf.generateCertificate(new java.io.ByteArrayInputStream(derBytes)); - - javax.net.ssl.TrustManagerFactory defaultTmf = javax.net.ssl.TrustManagerFactory.getInstance( - javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()); - defaultTmf.init((java.security.KeyStore) null); - final javax.net.ssl.X509TrustManager defaultTm = findX509TrustManager(defaultTmf); - - java.security.KeyStore ks = java.security.KeyStore.getInstance(java.security.KeyStore.getDefaultType()); - ks.load(null, null); - ks.setCertificateEntry("openintercept-ca", caCert); - javax.net.ssl.TrustManagerFactory openinterceptTmf = javax.net.ssl.TrustManagerFactory.getInstance( - javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()); - openinterceptTmf.init(ks); - final javax.net.ssl.X509TrustManager openinterceptTm = findX509TrustManager(openinterceptTmf); - - javax.net.ssl.X509TrustManager combined = new javax.net.ssl.X509TrustManager() { - @Override - public java.security.cert.X509Certificate[] getAcceptedIssuers() { - java.security.cert.X509Certificate[] a = defaultTm != null - ? defaultTm.getAcceptedIssuers() - : new java.security.cert.X509Certificate[0]; - java.security.cert.X509Certificate[] b = openinterceptTm != null - ? openinterceptTm.getAcceptedIssuers() - : new java.security.cert.X509Certificate[0]; - java.security.cert.X509Certificate[] result = new java.security.cert.X509Certificate[a.length + b.length]; - System.arraycopy(a, 0, result, 0, a.length); - System.arraycopy(b, 0, result, a.length, b.length); - return result; - } - - @Override - public void checkClientTrusted(java.security.cert.X509Certificate[] chain, String authType) - throws java.security.cert.CertificateException { - if (defaultTm != null) { - try { - defaultTm.checkClientTrusted(chain, authType); - return; - } catch (java.security.cert.CertificateException ignored) { - } - } - if (openinterceptTm != null) openinterceptTm.checkClientTrusted(chain, authType); - } - - @Override - public void checkServerTrusted(java.security.cert.X509Certificate[] chain, String authType) - throws java.security.cert.CertificateException { - if (defaultTm != null) { - try { - defaultTm.checkServerTrusted(chain, authType); - return; - } catch (java.security.cert.CertificateException ignored) { - } - } - if (openinterceptTm != null) openinterceptTm.checkServerTrusted(chain, authType); - } - }; - - javax.net.ssl.SSLContext sslContext = javax.net.ssl.SSLContext.getInstance("TLS"); - sslContext.init(null, new javax.net.ssl.TrustManager[]{combined}, null); - javax.net.ssl.SSLContext.setDefault(sslContext); - } - - private static javax.net.ssl.X509TrustManager findX509TrustManager( - javax.net.ssl.TrustManagerFactory tmf) { - for (javax.net.ssl.TrustManager tm : tmf.getTrustManagers()) { - if (tm instanceof javax.net.ssl.X509TrustManager) { - return (javax.net.ssl.X509TrustManager) tm; - } - } - return null; - } - - private static Map parseArgs(String args) { - Map parsedArgs = new HashMap<>(); - - if (args == null || args.isBlank()) { - return parsedArgs; - } - - for (String entry : args.split(",")) { - String[] parts = entry.split("=", 2); - - if (parts.length == 2 && !parts[0].isBlank()) { - parsedArgs.put(parts[0].trim(), parts[1].trim()); - } - } - - return parsedArgs; - } -} diff --git a/java/attach-helper/MANIFEST.MF b/java/attach-helper/MANIFEST.MF deleted file mode 100644 index 3aad0b6..0000000 --- a/java/attach-helper/MANIFEST.MF +++ /dev/null @@ -1,2 +0,0 @@ -Manifest-Version: 1.0 -Main-Class: dev.openintercept.attach.AttachHelper diff --git a/java/attach-helper/attach-helper.iml b/java/attach-helper/attach-helper.iml deleted file mode 100644 index c90834f..0000000 --- a/java/attach-helper/attach-helper.iml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/java/attach-helper/src/dev/openintercept/attach/AttachHelper.java b/java/attach-helper/src/dev/openintercept/attach/AttachHelper.java deleted file mode 100644 index 671d71e..0000000 --- a/java/attach-helper/src/dev/openintercept/attach/AttachHelper.java +++ /dev/null @@ -1,27 +0,0 @@ -package dev.openintercept.attach; - -import com.sun.tools.attach.VirtualMachine; - -public final class AttachHelper { - private AttachHelper() { - } - - public static void main(String[] args) throws Exception { - if (args.length < 2 || args.length > 3) { - System.err.println("Usage: AttachHelper [agent-args]"); - System.exit(2); - } - - String pid = args[0]; - String agentJar = args[1]; - String agentArgs = args.length == 3 ? args[2] : ""; - VirtualMachine virtualMachine = VirtualMachine.attach(pid); - - try { - virtualMachine.loadAgent(agentJar, agentArgs); - System.out.println("Attached OpenIntercept agent to JVM " + pid); - } finally { - virtualMachine.detach(); - } - } -} diff --git a/package-lock.json b/package-lock.json index 6d4712d..aec77d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "openintercept", + "name": "proctap", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "openintercept", + "name": "proctap", "version": "0.1.0", "workspaces": [ "apps/desktop" @@ -18,7 +18,7 @@ } }, "apps/desktop": { - "name": "@openintercept/desktop", + "name": "@proctap/desktop", "version": "0.1.0", "dependencies": { "@tauri-apps/api": "^2.1.1", @@ -433,10 +433,6 @@ "@octokit/openapi-types": "^27.0.0" } }, - "node_modules/@openintercept/desktop": { - "resolved": "apps/desktop", - "link": true - }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -491,6 +487,10 @@ "node": ">=12" } }, + "node_modules/@proctap/desktop": { + "resolved": "apps/desktop", + "link": true + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", diff --git a/package.json b/package.json index 6fac015..962d6ac 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "openintercept", + "name": "proctap", "version": "0.1.0", "private": true, "workspaces": [ @@ -10,6 +10,7 @@ "build": "npm --workspace apps/desktop run tauri:build", "release": "semantic-release", "set-version": "node scripts/set-version.mjs", + "timing-server": "node scripts/timing-test-server.mjs", "web:dev": "npm --workspace apps/desktop run dev", "typecheck": "npm --workspace apps/desktop run typecheck" }, diff --git a/scripts/build-java.mjs b/scripts/build-java.mjs deleted file mode 100644 index e7a2a91..0000000 --- a/scripts/build-java.mjs +++ /dev/null @@ -1,11 +0,0 @@ -import { execFileSync } from 'child_process'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); - -if (process.platform === 'win32') { - execFileSync('pwsh', ['-File', join(scriptDir, 'build-java.ps1')], { stdio: 'inherit' }); -} else { - execFileSync('bash', [join(scriptDir, 'build-java.sh')], { stdio: 'inherit' }); -} diff --git a/scripts/build-java.ps1 b/scripts/build-java.ps1 deleted file mode 100644 index 5d5fce1..0000000 --- a/scripts/build-java.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -if (-not (Get-Command javac -ErrorAction SilentlyContinue)) { - throw "javac was not found. Install a JDK and ensure javac is available on PATH." -} - -if (-not (Get-Command jar -ErrorAction SilentlyContinue)) { - throw "jar was not found. Install a JDK and ensure jar is available on PATH." -} - -$Root = Split-Path -Parent $PSScriptRoot -$BuildDir = Join-Path $Root "java/build" -$AgentClasses = Join-Path $BuildDir "agent-classes" -$HelperClasses = Join-Path $BuildDir "attach-helper-classes" - -New-Item -ItemType Directory -Path $AgentClasses -Force | Out-Null -New-Item -ItemType Directory -Path $HelperClasses -Force | Out-Null - -javac -d $AgentClasses (Join-Path $Root "java/agent/src/dev/openintercept/agent/OpenInterceptAgent.java") -jar cfm (Join-Path $BuildDir "openintercept-agent.jar") (Join-Path $Root "java/agent/MANIFEST.MF") -C $AgentClasses . - -javac --add-modules jdk.attach -d $HelperClasses (Join-Path $Root "java/attach-helper/src/dev/openintercept/attach/AttachHelper.java") -jar cfm (Join-Path $BuildDir "openintercept-attach-helper.jar") (Join-Path $Root "java/attach-helper/MANIFEST.MF") -C $HelperClasses . - -"Built Java artifacts in $BuildDir" diff --git a/scripts/build-java.sh b/scripts/build-java.sh deleted file mode 100755 index 0494c59..0000000 --- a/scripts/build-java.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if ! command -v javac &>/dev/null; then - echo "Error: javac was not found. Install a JDK and ensure javac is available on PATH." >&2 - exit 1 -fi - -if ! command -v jar &>/dev/null; then - echo "Error: jar was not found. Install a JDK and ensure jar is available on PATH." >&2 - exit 1 -fi - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -BUILD_DIR="$ROOT/java/build" -AGENT_CLASSES="$BUILD_DIR/agent-classes" -HELPER_CLASSES="$BUILD_DIR/attach-helper-classes" - -mkdir -p "$AGENT_CLASSES" -mkdir -p "$HELPER_CLASSES" - -javac -d "$AGENT_CLASSES" \ - "$ROOT/java/agent/src/dev/openintercept/agent/OpenInterceptAgent.java" - -jar cfm "$BUILD_DIR/openintercept-agent.jar" \ - "$ROOT/java/agent/MANIFEST.MF" \ - -C "$AGENT_CLASSES" . - -javac --add-modules jdk.attach -d "$HELPER_CLASSES" \ - "$ROOT/java/attach-helper/src/dev/openintercept/attach/AttachHelper.java" - -jar cfm "$BUILD_DIR/openintercept-attach-helper.jar" \ - "$ROOT/java/attach-helper/MANIFEST.MF" \ - -C "$HELPER_CLASSES" . - -echo "Built Java artifacts in $BUILD_DIR" diff --git a/scripts/create-java-truststore.ps1 b/scripts/create-java-truststore.ps1 deleted file mode 100644 index ddf76ee..0000000 --- a/scripts/create-java-truststore.ps1 +++ /dev/null @@ -1,56 +0,0 @@ -param( - [string]$Password = "changeit", - [string]$OutputPath = "" -) - -if (-not (Get-Command keytool -ErrorAction SilentlyContinue)) { - throw "keytool was not found. Install a JDK and ensure keytool is available on PATH." -} - -$Root = Split-Path -Parent $PSScriptRoot - -if ($OutputPath -eq "") { - $OutputPath = Join-Path $Root "java/build/openintercept-truststore.p12" -} - -$CandidateCertPaths = @() - -if ($env:LOCALAPPDATA) { - $CandidateCertPaths += Join-Path $env:LOCALAPPDATA "OpenIntercept/certs/openintercept-ca.pem" -} - -if ($env:XDG_DATA_HOME) { - $CandidateCertPaths += Join-Path $env:XDG_DATA_HOME "OpenIntercept/certs/openintercept-ca.pem" -} - -if ($env:HOME) { - $CandidateCertPaths += Join-Path $env:HOME ".local/share/OpenIntercept/certs/openintercept-ca.pem" - $CandidateCertPaths += Join-Path $env:HOME "Library/Application Support/OpenIntercept/certs/openintercept-ca.pem" -} - -$CaCertPath = $CandidateCertPaths | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 - -if (-not $CaCertPath) { - throw "OpenIntercept CA certificate was not found. Start OpenIntercept once so it can generate the local CA." -} - -$OutputParent = Split-Path -Parent $OutputPath -New-Item -ItemType Directory -Path $OutputParent -Force | Out-Null - -if (Test-Path -LiteralPath $OutputPath) { - Remove-Item -LiteralPath $OutputPath -Force -} - -keytool ` - -importcert ` - -noprompt ` - -trustcacerts ` - -alias openintercept-local-ca ` - -file $CaCertPath ` - -keystore $OutputPath ` - -storetype PKCS12 ` - -storepass $Password - -"Created Java truststore at $OutputPath" -"Source CA certificate: $CaCertPath" -"Password: $Password" diff --git a/scripts/create-java-truststore.sh b/scripts/create-java-truststore.sh deleted file mode 100755 index 600beb5..0000000 --- a/scripts/create-java-truststore.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -PASSWORD="changeit" -OUTPUT_PATH="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --password|-p) - if [[ $# -lt 2 || "$2" == -* ]]; then - echo "Error: $1 requires an argument" >&2 - exit 1 - fi - PASSWORD="$2" - shift 2 - ;; - --output|-o) - if [[ $# -lt 2 || "$2" == -* ]]; then - echo "Error: $1 requires an argument" >&2 - exit 1 - fi - OUTPUT_PATH="$2" - shift 2 - ;; - *) - echo "Error: Unknown argument: $1" >&2 - exit 1 - ;; - esac -done - -if ! command -v keytool &>/dev/null; then - echo "Error: keytool was not found. Install a JDK and ensure keytool is available on PATH." >&2 - exit 1 -fi - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" - -if [[ -z "$OUTPUT_PATH" ]]; then - OUTPUT_PATH="$ROOT/java/build/openintercept-truststore.p12" -fi - -CA_CERT_PATH="" -declare -a CANDIDATES=() - -if [[ -n "${XDG_DATA_HOME:-}" ]]; then - CANDIDATES+=("$XDG_DATA_HOME/OpenIntercept/certs/openintercept-ca.pem") -fi - -if [[ -n "${HOME:-}" ]]; then - CANDIDATES+=("$HOME/.local/share/OpenIntercept/certs/openintercept-ca.pem") - CANDIDATES+=("$HOME/Library/Application Support/OpenIntercept/certs/openintercept-ca.pem") -fi - -for candidate in "${CANDIDATES[@]}"; do - if [[ -f "$candidate" ]]; then - CA_CERT_PATH="$candidate" - break - fi -done - -if [[ -z "$CA_CERT_PATH" ]]; then - echo "Error: OpenIntercept CA certificate was not found. Start OpenIntercept once so it can generate the local CA." >&2 - exit 1 -fi - -mkdir -p "$(dirname "$OUTPUT_PATH")" - -if [[ -f "$OUTPUT_PATH" ]]; then - rm -f "$OUTPUT_PATH" -fi - -keytool \ - -importcert \ - -noprompt \ - -trustcacerts \ - -alias openintercept-local-ca \ - -file "$CA_CERT_PATH" \ - -keystore "$OUTPUT_PATH" \ - -storetype PKCS12 \ - -storepass "$PASSWORD" - -echo "Created Java truststore at $OUTPUT_PATH" -echo "Source CA certificate: $CA_CERT_PATH" -echo "Password: $PASSWORD" diff --git a/scripts/manage-jdk-truststore.ps1 b/scripts/manage-jdk-truststore.ps1 index b60e1bd..f7e093e 100644 --- a/scripts/manage-jdk-truststore.ps1 +++ b/scripts/manage-jdk-truststore.ps1 @@ -1,10 +1,12 @@ param( [switch]$List, + [switch]$All, [ValidateSet("status", "import", "remove")] [string]$Action = "", [string]$JavaHome = "", + [string]$CaCertPath = "", [string]$StorePass = "changeit", - [string]$Alias = "openintercept-local-ca" + [string]$Alias = "proctap-local-ca" ) $ErrorActionPreference = "Stop" @@ -29,26 +31,34 @@ function Find-Keytool { throw "keytool was not found. Install a JDK and ensure keytool is available on PATH." } -function Find-OpenInterceptCaCert { +function Find-ProcTapCaCert { + if ($CaCertPath -ne "") { + if (Test-Path -LiteralPath $CaCertPath) { + return $CaCertPath + } + + throw "CA certificate was not found at $CaCertPath" + } + $candidates = @() if ($env:LOCALAPPDATA) { - $candidates += Join-Path $env:LOCALAPPDATA "OpenIntercept/certs/openintercept-ca.pem" + $candidates += Join-Path $env:LOCALAPPDATA "ProcTap/mitm-ca/mitmproxy-ca-cert.pem" } if ($env:XDG_DATA_HOME) { - $candidates += Join-Path $env:XDG_DATA_HOME "OpenIntercept/certs/openintercept-ca.pem" + $candidates += Join-Path $env:XDG_DATA_HOME "ProcTap/mitm-ca/mitmproxy-ca-cert.pem" } if ($env:HOME) { - $candidates += Join-Path $env:HOME ".local/share/OpenIntercept/certs/openintercept-ca.pem" - $candidates += Join-Path $env:HOME "Library/Application Support/OpenIntercept/certs/openintercept-ca.pem" + $candidates += Join-Path $env:HOME ".local/share/ProcTap/mitm-ca/mitmproxy-ca-cert.pem" + $candidates += Join-Path $env:HOME "Library/Application Support/ProcTap/mitm-ca/mitmproxy-ca-cert.pem" } $cert = $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 if (-not $cert) { - throw "OpenIntercept CA certificate was not found. Start OpenIntercept once so it can generate the local CA." + throw "ProcTap CA certificate was not found. Start a capture in ProcTap once so mitmproxy can generate its CA." } return $cert @@ -268,8 +278,68 @@ if ($List) { return } +if ($All) { + $jdks = @(Get-JdkCandidates | Where-Object { $_.HasTruststore }) + + if ($jdks.Count -eq 0) { + throw "No JDK with lib/security/cacerts was found." + } + + if ($Action -eq "") { + $Action = "import" + } + + foreach ($jdk in $jdks) { + $keytool = $jdk.Keytool + $truststore = $jdk.Truststore + $imported = Test-CertificateImported -Keytool $keytool -Truststore $truststore + + "Selected JDK: $($jdk.JavaHome)" + "Truststore: $truststore" + + switch ($Action) { + "status" { + if ($imported) { + "ProcTap CA is already imported with alias '$Alias'." + } else { + "ProcTap CA is not imported." + } + } + "import" { + if ($imported) { + "ProcTap CA is already imported with alias '$Alias'." + } else { + $caCert = Find-ProcTapCaCert + Import-Certificate -Keytool $keytool -Truststore $truststore -CaCert $caCert + + if (Test-CertificateImported -Keytool $keytool -Truststore $truststore) { + "ProcTap CA imported successfully." + } else { + "Warning: import command completed for $($jdk.JavaHome), but the certificate alias was not found afterwards." + } + } + } + "remove" { + if (-not $imported) { + "ProcTap CA is not imported. Nothing to remove." + } else { + Remove-Certificate -Keytool $keytool -Truststore $truststore + + if (Test-CertificateImported -Keytool $keytool -Truststore $truststore) { + "Warning: remove command completed for $($jdk.JavaHome), but the certificate alias still exists." + } else { + "ProcTap CA removed successfully." + } + } + } + } + } + + return +} + if ($Action -eq "") { - "OpenIntercept JDK truststore manager" + "ProcTap JDK truststore manager" if ($JavaHome -eq "") { $script:PreloadedJdks = @(Get-JdkCandidates) @@ -286,8 +356,8 @@ if ($Action -eq "") { } "[1] Show certificate status" - "[2] Import OpenIntercept CA into a JDK truststore" - "[3] Remove OpenIntercept CA from a JDK truststore" + "[2] Import ProcTap CA into a JDK truststore" + "[3] Remove ProcTap CA from a JDK truststore" $choice = Read-Host "Choose an action" $Action = switch ($choice) { @@ -309,29 +379,29 @@ $imported = Test-CertificateImported -Keytool $keytool -Truststore $truststore switch ($Action) { "status" { if ($imported) { - "OpenIntercept CA is already imported with alias '$Alias'." + "ProcTap CA is already imported with alias '$Alias'." } else { - "OpenIntercept CA is not imported." + "ProcTap CA is not imported." } } "import" { if ($imported) { - "OpenIntercept CA is already imported with alias '$Alias'." + "ProcTap CA is already imported with alias '$Alias'." return } - $caCert = Find-OpenInterceptCaCert + $caCert = Find-ProcTapCaCert Import-Certificate -Keytool $keytool -Truststore $truststore -CaCert $caCert if (Test-CertificateImported -Keytool $keytool -Truststore $truststore) { - "OpenIntercept CA imported successfully." + "ProcTap CA imported successfully." } else { throw "Import command completed, but the certificate alias was not found afterwards." } } "remove" { if (-not $imported) { - "OpenIntercept CA is not imported. Nothing to remove." + "ProcTap CA is not imported. Nothing to remove." return } @@ -341,6 +411,6 @@ switch ($Action) { throw "Remove command completed, but the certificate alias still exists." } - "OpenIntercept CA removed successfully." + "ProcTap CA removed successfully." } } diff --git a/scripts/manage-jdk-truststore.sh b/scripts/manage-jdk-truststore.sh index a06cee8..dc70839 100755 --- a/scripts/manage-jdk-truststore.sh +++ b/scripts/manage-jdk-truststore.sh @@ -1,11 +1,13 @@ #!/usr/bin/env bash set -euo pipefail -ALIAS="openintercept-local-ca" +ALIAS="proctap-local-ca" STORE_PASS="changeit" JAVA_HOME_ARG="" ACTION="" LIST_ONLY=false +ALL_JDKS_MODE=false +CA_CERT_OVERRIDE="" while [[ $# -gt 0 ]]; do case "$1" in @@ -13,6 +15,18 @@ while [[ $# -gt 0 ]]; do LIST_ONLY=true shift ;; + --all) + ALL_JDKS_MODE=true + shift + ;; + --ca-cert) + if [[ $# -lt 2 || "$2" == -* ]]; then + echo "Error: $1 requires an argument" >&2 + exit 1 + fi + CA_CERT_OVERRIDE="$2" + shift 2 + ;; -a|--action) if [[ $# -lt 2 || "$2" == -* ]]; then echo "Error: $1 requires an argument (status|import|remove)" >&2 @@ -64,15 +78,24 @@ contains_element() { } find_ca_cert() { + if [[ -n "$CA_CERT_OVERRIDE" ]]; then + if [[ -f "$CA_CERT_OVERRIDE" ]]; then + echo "$CA_CERT_OVERRIDE" + return 0 + fi + echo "Error: CA certificate was not found at $CA_CERT_OVERRIDE" >&2 + return 1 + fi + local candidates=() if [[ -n "${XDG_DATA_HOME:-}" ]]; then - candidates+=("$XDG_DATA_HOME/OpenIntercept/certs/openintercept-ca.pem") + candidates+=("$XDG_DATA_HOME/ProcTap/mitm-ca/mitmproxy-ca-cert.pem") fi if [[ -n "${HOME:-}" ]]; then - candidates+=("$HOME/.local/share/OpenIntercept/certs/openintercept-ca.pem") - candidates+=("$HOME/Library/Application Support/OpenIntercept/certs/openintercept-ca.pem") + candidates+=("$HOME/.local/share/ProcTap/mitm-ca/mitmproxy-ca-cert.pem") + candidates+=("$HOME/Library/Application Support/ProcTap/mitm-ca/mitmproxy-ca-cert.pem") fi for candidate in "${candidates[@]}"; do @@ -82,7 +105,7 @@ find_ca_cert() { fi done - echo "Error: OpenIntercept CA certificate was not found. Start OpenIntercept once so it can generate the local CA." >&2 + echo "Error: ProcTap CA certificate was not found. Start a capture in ProcTap once so mitmproxy can generate its CA." >&2 return 1 } @@ -228,6 +251,70 @@ if $LIST_ONLY; then exit 0 fi +if $ALL_JDKS_MODE; then + mapfile -t ALL_JDKS < <(discover_jdks) + VALID_JDKS=() + for entry in "${ALL_JDKS[@]}"; do + IFS='|' read -r _ _ has_ts <<< "$entry" + [[ "$has_ts" == "true" ]] && VALID_JDKS+=("$entry") + done + + if [[ ${#VALID_JDKS[@]} -eq 0 ]]; then + echo "Error: No JDK with lib/security/cacerts was found." >&2 + exit 1 + fi + + [[ -z "$ACTION" ]] && ACTION="import" + + for entry in "${VALID_JDKS[@]}"; do + IFS='|' read -r ALL_TARGET_HOME ALL_TARGET_TRUSTSTORE _ <<< "$entry" + ALL_KEYTOOL="$(find_keytool "$ALL_TARGET_HOME")" + + echo "Selected JDK: $ALL_TARGET_HOME" + echo "Truststore: $ALL_TARGET_TRUSTSTORE" + + case "$ACTION" in + status) + if cert_imported "$ALL_KEYTOOL" "$ALL_TARGET_TRUSTSTORE"; then + echo "ProcTap CA is already imported with alias '$ALIAS'." + else + echo "ProcTap CA is not imported." + fi + ;; + import) + if cert_imported "$ALL_KEYTOOL" "$ALL_TARGET_TRUSTSTORE"; then + echo "ProcTap CA is already imported with alias '$ALIAS'." + else + CA_CERT="$(find_ca_cert)" + do_import "$ALL_KEYTOOL" "$ALL_TARGET_TRUSTSTORE" "$CA_CERT" + if cert_imported "$ALL_KEYTOOL" "$ALL_TARGET_TRUSTSTORE"; then + echo "ProcTap CA imported successfully." + else + echo "Warning: import command completed for $ALL_TARGET_HOME, but the certificate alias was not found afterwards." >&2 + fi + fi + ;; + remove) + if ! cert_imported "$ALL_KEYTOOL" "$ALL_TARGET_TRUSTSTORE"; then + echo "ProcTap CA is not imported. Nothing to remove." + else + do_remove "$ALL_KEYTOOL" "$ALL_TARGET_TRUSTSTORE" + if cert_imported "$ALL_KEYTOOL" "$ALL_TARGET_TRUSTSTORE"; then + echo "Warning: remove command completed for $ALL_TARGET_HOME, but the certificate alias still exists." >&2 + else + echo "ProcTap CA removed successfully." + fi + fi + ;; + *) + echo "Error: Unknown action '$ACTION'. Use status, import, or remove." >&2 + exit 1 + ;; + esac + done + exit 0 +fi + TARGET_HOME="" TARGET_TRUSTSTORE="" @@ -252,7 +339,7 @@ else exit 1 fi - echo "OpenIntercept JDK truststore manager" + echo "ProcTap JDK truststore manager" echo "" echo "Detected JDKs:" print_jdk_list "${VALID_JDKS[@]}" @@ -260,8 +347,8 @@ else if [[ -z "$ACTION" ]]; then echo "[1] Show certificate status" - echo "[2] Import OpenIntercept CA into a JDK truststore" - echo "[3] Remove OpenIntercept CA from a JDK truststore" + echo "[2] Import ProcTap CA into a JDK truststore" + echo "[3] Remove ProcTap CA from a JDK truststore" read -r -p "Choose an action: " action_choice case "$action_choice" in 1) ACTION="status" ;; @@ -292,20 +379,20 @@ echo "Truststore: $TARGET_TRUSTSTORE" case "$ACTION" in status) if cert_imported "$KEYTOOL" "$TARGET_TRUSTSTORE"; then - echo "OpenIntercept CA is already imported with alias '$ALIAS'." + echo "ProcTap CA is already imported with alias '$ALIAS'." else - echo "OpenIntercept CA is not imported." + echo "ProcTap CA is not imported." fi ;; import) if cert_imported "$KEYTOOL" "$TARGET_TRUSTSTORE"; then - echo "OpenIntercept CA is already imported with alias '$ALIAS'." + echo "ProcTap CA is already imported with alias '$ALIAS'." exit 0 fi CA_CERT="$(find_ca_cert)" do_import "$KEYTOOL" "$TARGET_TRUSTSTORE" "$CA_CERT" if cert_imported "$KEYTOOL" "$TARGET_TRUSTSTORE"; then - echo "OpenIntercept CA imported successfully." + echo "ProcTap CA imported successfully." else echo "Error: Import command completed, but the certificate alias was not found afterwards." >&2 exit 1 @@ -313,7 +400,7 @@ case "$ACTION" in ;; remove) if ! cert_imported "$KEYTOOL" "$TARGET_TRUSTSTORE"; then - echo "OpenIntercept CA is not imported. Nothing to remove." + echo "ProcTap CA is not imported. Nothing to remove." exit 0 fi do_remove "$KEYTOOL" "$TARGET_TRUSTSTORE" @@ -321,7 +408,7 @@ case "$ACTION" in echo "Error: Remove command completed, but the certificate alias still exists." >&2 exit 1 fi - echo "OpenIntercept CA removed successfully." + echo "ProcTap CA removed successfully." ;; "") echo "Error: No action specified. Use -a status|import|remove or run interactively." >&2 diff --git a/scripts/set-version.mjs b/scripts/set-version.mjs index b51ac3b..f2157c6 100644 --- a/scripts/set-version.mjs +++ b/scripts/set-version.mjs @@ -28,10 +28,10 @@ for (const file of jsonFiles) { const cargoToml = "apps/desktop/src-tauri/Cargo.toml"; const cargo = readFileSync(cargoToml, "utf8").replace( - /(^\[package\]\r?\nname = "openintercept-desktop"\r?\nversion = ")([^"]+)(")/m, + /(^\[package\]\r?\nname = "proctap-desktop"\r?\nversion = ")([^"]+)(")/m, `$1${version}$3`, ); writeFileSync(cargoToml, cargo); -console.log(`Set OpenIntercept version to ${version}`); +console.log(`Set ProcTap version to ${version}`); diff --git a/scripts/timing-test-server.mjs b/scripts/timing-test-server.mjs new file mode 100644 index 0000000..70ddf2c --- /dev/null +++ b/scripts/timing-test-server.mjs @@ -0,0 +1,150 @@ +import http from 'node:http'; +import { URL } from 'node:url'; + +const DEFAULT_PORT = 4317; +const port = Number(process.env.PORT ?? process.argv[2] ?? DEFAULT_PORT); + +function intParam(url, name, fallback, min = 0, max = Number.MAX_SAFE_INTEGER) { + const raw = url.searchParams.get(name); + if (raw === null || raw.trim() === '') return fallback; + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(max, Math.max(min, Math.trunc(parsed))); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function writeText(res, status, text, headers = {}) { + res.writeHead(status, { + 'content-type': 'text/plain; charset=utf-8', + 'cache-control': 'no-store', + ...headers, + }); + res.end(text); +} + +async function readRequestBody(req, perChunkDelayMs = 0) { + const startedAt = process.hrtime.bigint(); + let bytes = 0; + let chunks = 0; + + for await (const chunk of req) { + bytes += chunk.length; + chunks += 1; + if (perChunkDelayMs > 0) { + await sleep(perChunkDelayMs); + } + } + + const endedAt = process.hrtime.bigint(); + return { + bytes, + chunks, + durationMs: Number(endedAt - startedAt) / 1_000_000, + }; +} + +async function writeChunkedResponse(res, url) { + const waitMs = intParam(url, 'wait', 0); + const chunks = intParam(url, 'chunks', 5, 1, 500); + const intervalMs = intParam(url, 'interval', 500); + const size = intParam(url, 'size', 1024, 1, 1024 * 1024); + const status = intParam(url, 'status', 200, 100, 599); + + await sleep(waitMs); + res.writeHead(status, { + 'content-type': 'text/plain; charset=utf-8', + 'cache-control': 'no-store', + 'x-test-wait-ms': String(waitMs), + 'x-test-chunks': String(chunks), + 'x-test-interval-ms': String(intervalMs), + 'x-test-chunk-size': String(size), + }); + + const payload = 'x'.repeat(size); + for (let i = 1; i <= chunks; i += 1) { + res.write(`chunk ${i}/${chunks} ${payload}\n`); + if (i < chunks) { + await sleep(intervalMs); + } + } + res.end(); +} + +function helpText() { + return `ProcTap timing test server + +Endpoints: + GET /ttfb?wait=1500 + Delays before the first response byte. Use this to verify wait/TTFB. + + GET /receive?chunks=5&interval=400&size=1024 + Sends the first chunk immediately, then spaces later chunks. Use this to verify receive. + + GET /combo?wait=700&chunks=4&interval=300&size=1024 + Combines wait/TTFB delay and slow receive. + + POST /upload + Reads the request body and reports server-observed upload duration. To verify send in ProcTap, + send a large body slowly from the client, for example: + curl --limit-rate 20k --data-binary @large.bin http://127.0.0.1:${port}/upload + + POST /slow-read?delay=50 + Adds a delay after each received request chunk. With a large body this can create backpressure, + but client-side throttling is usually more predictable for testing send. + +Examples: + curl http://127.0.0.1:${port}/ttfb?wait=1500 + curl http://127.0.0.1:${port}/receive?chunks=6\&interval=250\&size=4096 + curl http://127.0.0.1:${port}/combo?wait=800\&chunks=5\&interval=300 +`; +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? `127.0.0.1:${port}`}`); + const startedAt = new Date().toISOString(); + console.log(`${startedAt} ${req.method} ${url.pathname}${url.search}`); + + try { + if (req.method === 'GET' && url.pathname === '/') { + writeText(res, 200, helpText()); + return; + } + + if (req.method === 'GET' && (url.pathname === '/ttfb' || url.pathname === '/receive' || url.pathname === '/combo')) { + await writeChunkedResponse(res, url); + return; + } + + if (req.method === 'POST' && (url.pathname === '/upload' || url.pathname === '/slow-read')) { + const perChunkDelayMs = url.pathname === '/slow-read' ? intParam(url, 'delay', 50) : 0; + const body = await readRequestBody(req, perChunkDelayMs); + writeText(res, 200, JSON.stringify({ + bytes: body.bytes, + chunks: body.chunks, + serverObservedUploadMs: Math.round(body.durationMs), + perChunkReadDelayMs, + }, null, 2)); + return; + } + + writeText(res, 404, `Unknown endpoint. Try http://127.0.0.1:${port}/\n`); + } catch (err) { + console.error(err); + if (!res.headersSent) { + writeText(res, 500, err instanceof Error ? err.stack ?? err.message : String(err)); + } else { + res.destroy(err instanceof Error ? err : undefined); + } + } +}); + +server.listen(port, '127.0.0.1', () => { + console.log(`ProcTap timing test server listening on http://127.0.0.1:${port}`); +}); + +process.on('SIGINT', () => { + server.close(() => process.exit(0)); +}); From d91d9acaa7cda890497afe526b1555acb128da11 Mon Sep 17 00:00:00 2001 From: Flaykz Date: Tue, 7 Jul 2026 19:14:31 +1100 Subject: [PATCH 2/8] docs: add mitmdump auto-download design spec and CLAUDE.md --- CLAUDE.md | 1 + ...026-07-07-mitmdump-auto-download-design.md | 142 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/superpowers/specs/2026-07-07-mitmdump-auto-download-design.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/docs/superpowers/specs/2026-07-07-mitmdump-auto-download-design.md b/docs/superpowers/specs/2026-07-07-mitmdump-auto-download-design.md new file mode 100644 index 0000000..bf1fe64 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-mitmdump-auto-download-design.md @@ -0,0 +1,142 @@ +# mitmdump Auto-Download Design + +**Date:** 2026-07-07 +**Scope:** Linux and Windows only (macOS excluded — no single-file standalone binary) +**Roadmap:** P7 + +## Problem + +When the user activates the proxy (start capture or explicit proxy), ProcTap calls +`find_mitmdump()` which looks for `mitmdump` on PATH. If absent, the Tauri command +returns an error string to the UI — the user sees a raw error and has no path forward. + +## Goal + +On first use, if `mitmdump` is not already installed, ProcTap downloads and caches the +standalone binary automatically, showing a progress indicator. The user never has to +install anything manually. + +## User Experience + +1. User starts the app. +2. On mount, the frontend calls the new `ensure_mitmdump` Tauri command. +3. If mitmdump is already available (PATH or local cache): command returns immediately, + normal UI appears. +4. If not: a full-screen setup overlay appears — "Setting up capture engine…" — with a + progress bar fed by `mitmdump-progress` events. +5. Once the download and extraction complete, the overlay dismisses and the normal UI + renders. The user clicks Start capture as usual. +6. On all subsequent launches the cached binary is found immediately; the overlay never + appears again. + +## Architecture + +### mitmdump Resolution Order (extended `find_mitmdump`) + +1. `PROCTAP_MITMDUMP` environment variable (existing) +2. `mitmdump` / `mitmdump.exe` on PATH (existing) +3. `local_app_dir()/mitmdump[.exe]` — the auto-download cache (new) + +### New: `crates/mitm_engine/src/download.rs` + +Public async function: + +```rust +pub async fn download_mitmdump( + dest_dir: &Path, + on_progress: impl Fn(u64, u64) + Send + Sync + 'static, +) -> Result +``` + +Behaviour: +- Target version: **11.1.3** (hardcoded constant `MITMPROXY_VERSION`) +- Linux URL: `https://github.com/mitmproxy/mitmproxy/releases/download/v{VERSION}/mitmproxy-{VERSION}-linux-x86_64.tar.gz` +- Windows URL: `https://github.com/mitmproxy/mitmproxy/releases/download/v{VERSION}/mitmproxy-{VERSION}-windows-x86_64.zip` +- Streams the download with `reqwest` (feature `stream`), calling `on_progress(downloaded, total)` per chunk +- Extracts only `mitmdump` / `mitmdump.exe` from the archive (skips everything else) + - Linux: `flate2` + `tar` crate to walk `.tar.gz` entries + - Windows: `zip` crate to walk `.zip` entries +- On Linux: sets the extracted file executable (`chmod +x` via `std::fs::Permissions`) +- Writes the binary to `dest_dir/mitmdump[.exe]` +- Returns the final path + +New dependencies for `crates/mitm_engine`: +- `reqwest` with `{ version, features = ["stream"] }` +- `flate2` +- `tar` +- `zip` (unconditional — simpler than cfg-gating) +- `tokio` already present + +### Modified: `find_mitmdump` in `crates/mitm_engine/src/lib.rs` + +Add a new `find_mitmdump_with_local(local_dir: &Path) -> Result` alongside the +existing `find_mitmdump()`. The new function adds step 3 (local cache lookup) after the +PATH search. The existing `find_mitmdump()` delegates to `find_mitmdump_with_local` with +a dummy/empty path so existing call sites in tests and the spike binary compile unchanged. + +### New Tauri Command: `ensure_mitmdump` + +In `apps/desktop/src-tauri/src/main.rs`: + +```rust +#[tauri::command] +async fn ensure_mitmdump(app_handle: tauri::AppHandle) -> Result<(), String> +``` + +Logic: +1. Call `find_mitmdump_with_local(&local_app_dir())` — if found, return `Ok(())`. +2. If not found, call `download_mitmdump(&local_app_dir(), |downloaded, total| { ... })`. +3. Inside the closure, emit a `mitmdump-progress` event: + `app_handle.emit("mitmdump-progress", MitmdumpProgress { downloaded, total })` +4. On success, return `Ok(())`. On error, return `Err(error.to_string())`. + +Event payload type: + +```rust +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct MitmdumpProgress { downloaded: u64, total: u64 } +``` + +### Modified: `start_capture` and `start_explicit_proxy` + +Replace the inline `find_mitmdump()` call with `find_mitmdump_with_local(&local_app_dir())` +so they use the cached binary if the frontend somehow called start before ensure. + +### Frontend: `App.tsx` + +New state: + +```ts +type MitmdumpSetupState = + | { phase: 'checking' } + | { phase: 'downloading'; downloaded: number; total: number } + | { phase: 'ready' } + | { phase: 'error'; message: string }; +``` + +On mount: +1. Set state to `{ phase: 'checking' }`. +2. Listen to `mitmdump-progress` events — update state to `{ phase: 'downloading', ... }`. +3. `invoke('ensure_mitmdump')` — on resolve set `{ phase: 'ready' }`, on reject set `{ phase: 'error', message }`. + +Render: +- `checking` / `downloading`: full-screen centered overlay, title "Setting up capture engine", + subtitle "Downloading mitmdump (X / Y MB)…", `` element. +- `error`: overlay with error text and a Retry button. +- `ready`: normal app UI. + +## Error Handling + +- Network error during download: surface message in the error overlay with a Retry button + (re-invokes `ensure_mitmdump`). +- Partial download left on disk: `download_mitmdump` writes to a `.tmp` path and renames + atomically on success, so a crash mid-download never leaves a corrupt binary. +- Extraction fails (e.g. unexpected archive layout from a future mitmproxy release): + return error — user sees the error overlay. + +## What Is NOT in Scope + +- macOS support (no single-file binary available from mitmproxy). +- Automatic version upgrades (version is hardcoded; the user can delete the cache to force a re-download). +- Download progress for the existing `start_capture` path when called before `ensure_mitmdump`. From f8f6803526a6b7a9887eb5625d85e942ef040323 Mon Sep 17 00:00:00 2001 From: Flaykz Date: Tue, 7 Jul 2026 19:21:38 +1100 Subject: [PATCH 3/8] feat(mitm_engine): add download module for mitmdump auto-download --- crates/mitm_engine/Cargo.toml | 8 +++ crates/mitm_engine/src/download.rs | 110 +++++++++++++++++++++++++++++ crates/mitm_engine/src/lib.rs | 1 + 3 files changed, 119 insertions(+) create mode 100644 crates/mitm_engine/src/download.rs diff --git a/crates/mitm_engine/Cargo.toml b/crates/mitm_engine/Cargo.toml index 5198f94..2541a5e 100644 --- a/crates/mitm_engine/Cargo.toml +++ b/crates/mitm_engine/Cargo.toml @@ -9,12 +9,20 @@ anyhow.workspace = true base64 = "0.22" capture = { path = "../capture" } fault_rules = { path = "../fault_rules" } +flate2 = "1.0" +futures-util = { version = "0.3", default-features = false, features = ["std"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } serde.workspace = true serde_json.workspace = true sha2.workspace = true +tar = "0.4" tokio.workspace = true uuid.workspace = true which = "6" +zip = { version = "2", default-features = false, features = ["deflate"] } + +[dev-dependencies] +tempfile = "3" [[bin]] name = "mitm_engine_spike" diff --git a/crates/mitm_engine/src/download.rs b/crates/mitm_engine/src/download.rs new file mode 100644 index 0000000..16d3d69 --- /dev/null +++ b/crates/mitm_engine/src/download.rs @@ -0,0 +1,110 @@ +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; + +pub const MITMPROXY_VERSION: &str = "11.1.3"; + +fn archive_url() -> String { + if cfg!(windows) { + format!( + "https://github.com/mitmproxy/mitmproxy/releases/download/v{v}/mitmproxy-{v}-windows-x86_64.zip", + v = MITMPROXY_VERSION + ) + } else { + format!( + "https://github.com/mitmproxy/mitmproxy/releases/download/v{v}/mitmproxy-{v}-linux-x86_64.tar.gz", + v = MITMPROXY_VERSION + ) + } +} + +pub async fn download_mitmdump( + dest_dir: &Path, + on_progress: impl Fn(u64, u64) + Send + Sync + 'static, +) -> Result { + let exe_name = if cfg!(windows) { "mitmdump.exe" } else { "mitmdump" }; + let dest = dest_dir.join(exe_name); + let tmp = dest_dir.join(if cfg!(windows) { "mitmdump.exe.tmp" } else { "mitmdump.tmp" }); + + let url = archive_url(); + let response = reqwest::get(&url) + .await + .with_context(|| format!("failed to fetch {url}"))?; + + let total = response.content_length().unwrap_or(0); + let mut downloaded = 0u64; + let mut archive_bytes: Vec = Vec::new(); + + use futures_util::StreamExt as _; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("download stream error")?; + downloaded += chunk.len() as u64; + archive_bytes.extend_from_slice(&chunk); + on_progress(downloaded, total); + } + + extract_mitmdump(&archive_bytes, &tmp)?; + std::fs::rename(&tmp, &dest).context("failed to move mitmdump to final path")?; + + Ok(dest) +} + +#[cfg(not(windows))] +fn extract_mitmdump(archive_bytes: &[u8], dest: &Path) -> Result<()> { + use flate2::read::GzDecoder; + use tar::Archive; + + let gz = GzDecoder::new(archive_bytes); + let mut archive = Archive::new(gz); + + for entry in archive.entries().context("failed to read tar entries")? { + let mut entry = entry.context("failed to read tar entry")?; + let path = entry.path().context("failed to read entry path")?; + if path.file_name() == Some(std::ffi::OsStr::new("mitmdump")) { + entry.unpack(dest).context("failed to extract mitmdump")?; + + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(dest)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(dest, perms)?; + + return Ok(()); + } + } + + anyhow::bail!("mitmdump not found in archive") +} + +#[cfg(windows)] +fn extract_mitmdump(archive_bytes: &[u8], dest: &Path) -> Result<()> { + use std::io::{Cursor, Read}; + use zip::ZipArchive; + + let cursor = Cursor::new(archive_bytes); + let mut archive = ZipArchive::new(cursor).context("failed to open zip")?; + + for i in 0..archive.len() { + let mut file = archive.by_index(i).context("failed to read zip entry")?; + if file.name().ends_with("mitmdump.exe") { + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .context("failed to read mitmdump.exe from zip")?; + std::fs::write(dest, bytes).context("failed to write mitmdump.exe")?; + return Ok(()); + } + } + + anyhow::bail!("mitmdump.exe not found in archive") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn archive_url_contains_version() { + let url = archive_url(); + assert!(url.contains(MITMPROXY_VERSION)); + assert!(url.starts_with("https://github.com/mitmproxy/mitmproxy/releases/download/")); + } +} diff --git a/crates/mitm_engine/src/lib.rs b/crates/mitm_engine/src/lib.rs index 99cdcb6..3e100cd 100644 --- a/crates/mitm_engine/src/lib.rs +++ b/crates/mitm_engine/src/lib.rs @@ -15,6 +15,7 @@ use tokio::process::{Child, Command}; use tokio::sync::watch; use uuid::Uuid; +pub mod download; pub mod trust; /// Path to `bridge_addon.py`, resolved relative to this crate at compile time so the From 7a8058729407aa6c81ab06ccc28f207bcbf42f52 Mon Sep 17 00:00:00 2001 From: Flaykz Date: Tue, 7 Jul 2026 19:22:59 +1100 Subject: [PATCH 4/8] feat(mitm_engine): add find_mitmdump_with_local with local cache fallback --- crates/mitm_engine/src/lib.rs | 48 +++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/mitm_engine/src/lib.rs b/crates/mitm_engine/src/lib.rs index 3e100cd..9f3e4f0 100644 --- a/crates/mitm_engine/src/lib.rs +++ b/crates/mitm_engine/src/lib.rs @@ -646,3 +646,51 @@ pub fn find_mitmdump() -> Result { format!("{exe_name} not found on PATH; install mitmproxy or set PROCTAP_MITMDUMP") }) } + +/// Like [`find_mitmdump`], but also checks `local_dir/mitmdump[.exe]` as a third +/// fallback after PATH. This is where [`download::download_mitmdump`] writes its +/// cached binary. +pub fn find_mitmdump_with_local(local_dir: &std::path::Path) -> Result { + if let Ok(path) = find_mitmdump() { + return Ok(path); + } + + let exe_name = if cfg!(windows) { "mitmdump.exe" } else { "mitmdump" }; + let local_path = local_dir.join(exe_name); + if local_path.exists() { + return Ok(local_path); + } + + anyhow::bail!( + "{exe_name} not found on PATH or in {}; use ensure_mitmdump to download it automatically", + local_dir.display() + ) +} + +#[cfg(test)] +mod find_tests { + use super::*; + + #[test] + fn find_mitmdump_with_local_finds_file_in_dir() { + let dir = tempfile::tempdir().expect("tempdir"); + let exe_name = if cfg!(windows) { "mitmdump.exe" } else { "mitmdump" }; + let fake_bin = dir.path().join(exe_name); + std::fs::write(&fake_bin, b"fake").expect("write fake bin"); + + let result = find_mitmdump_with_local(dir.path()); + assert!(result.is_ok(), "expected to find mitmdump in local dir"); + assert_eq!(result.unwrap(), fake_bin); + } + + #[test] + fn find_mitmdump_with_local_returns_err_when_nothing_found() { + let dir = tempfile::tempdir().expect("tempdir"); + let exe_name = if cfg!(windows) { "mitmdump.exe" } else { "mitmdump" }; + if which::which(exe_name).is_ok() { + return; + } + let result = find_mitmdump_with_local(dir.path()); + assert!(result.is_err()); + } +} From 6157d9e924a142c38f3d2cb688d202a6977bbf54 Mon Sep 17 00:00:00 2001 From: Flaykz Date: Tue, 7 Jul 2026 19:24:49 +1100 Subject: [PATCH 5/8] feat(desktop): add ensure_mitmdump command with download progress events --- apps/desktop/src-tauri/src/main.rs | 31 ++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 1453887..7783cd3 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -50,6 +50,13 @@ struct AppLogEntry { message: String, } +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct MitmdumpProgress { + downloaded: u64, + total: u64, +} + fn push_app_log( logs: &AppLogState, level: impl Into, @@ -261,7 +268,7 @@ async fn restart_local_engine( mitm_engine::ProxyMode::Regular { .. } => unreachable!("local restart never uses regular mode"), }; - let mitmdump_path = mitm_engine::find_mitmdump().map_err(|error| error.to_string())?; + let mitmdump_path = mitm_engine::find_mitmdump_with_local(&local_app_dir()).map_err(|error| error.to_string())?; let data_dir = local_app_dir(); let confdir = data_dir.join("mitm-ca"); std::fs::create_dir_all(&confdir).map_err(|error| error.to_string())?; @@ -546,7 +553,7 @@ async fn start_explicit_proxy( stop_explicit_proxy_session(&session); - let mitmdump_path = mitm_engine::find_mitmdump().map_err(|error| error.to_string())?; + let mitmdump_path = mitm_engine::find_mitmdump_with_local(&local_app_dir()).map_err(|error| error.to_string())?; let data_dir = local_app_dir(); let confdir = data_dir.join("mitm-ca"); std::fs::create_dir_all(&confdir).map_err(|error| error.to_string())?; @@ -873,6 +880,25 @@ fn broadcast_enabled_rules( Ok(all) } +#[tauri::command] +async fn ensure_mitmdump(app_handle: tauri::AppHandle) -> Result<(), String> { + let local_dir = local_app_dir(); + std::fs::create_dir_all(&local_dir).map_err(|e| e.to_string())?; + + if mitm_engine::find_mitmdump_with_local(&local_dir).is_ok() { + return Ok(()); + } + + let handle = app_handle.clone(); + mitm_engine::download::download_mitmdump(&local_dir, move |downloaded, total| { + let _ = handle.emit("mitmdump-progress", MitmdumpProgress { downloaded, total }); + }) + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} + fn workspace_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .ancestors() @@ -983,6 +1009,7 @@ fn main() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + ensure_mitmdump, list_processes, list_app_logs, clear_app_logs, From b4b5113e2415714007e8ff06c9ba30a9a53d7df2 Mon Sep 17 00:00:00 2001 From: Flaykz Date: Tue, 7 Jul 2026 19:28:49 +1100 Subject: [PATCH 6/8] feat(frontend): show setup overlay while mitmdump is being downloaded --- apps/desktop/src/App.tsx | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index e6de104..15a1cf5 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -43,6 +43,12 @@ type CaStatus = { fingerprintSha256: string; }; +type MitmdumpSetupState = + | { phase: 'checking' } + | { phase: 'downloading'; downloaded: number; total: number } + | { phase: 'ready' } + | { phase: 'error'; message: string }; + type AppLogEntry = { id: number; timestampMs: number; @@ -381,6 +387,8 @@ export function App() { exchange: CapturedExchange; } | null>(null); + const [mitmdumpSetup, setMitmdumpSetup] = useState({ phase: 'checking' }); + const methods = [...new Set(exchanges.map((e) => e.method))].sort(); function matchesFilters(exchange: CapturedExchange): boolean { @@ -472,6 +480,34 @@ export function App() { } } + useEffect(() => { + let unlistenProgress: (() => void) | undefined; + + (async () => { + unlistenProgress = await listen<{ downloaded: number; total: number }>( + 'mitmdump-progress', + (event) => { + setMitmdumpSetup({ + phase: 'downloading', + downloaded: event.payload.downloaded, + total: event.payload.total, + }); + }, + ); + + try { + await invoke('ensure_mitmdump'); + setMitmdumpSetup({ phase: 'ready' }); + } catch (err) { + setMitmdumpSetup({ phase: 'error', message: String(err) }); + } + })(); + + return () => { + unlistenProgress?.(); + }; + }, []); + useEffect(() => { void refresh(); const interval = window.setInterval(() => void refresh(), 1500); @@ -901,6 +937,43 @@ export function App() { document.addEventListener('mouseup', onMouseUp); }, []); + if (mitmdumpSetup.phase === 'checking' || mitmdumpSetup.phase === 'downloading') { + const downloaded = mitmdumpSetup.phase === 'downloading' ? mitmdumpSetup.downloaded : 0; + const total = mitmdumpSetup.phase === 'downloading' ? mitmdumpSetup.total : 0; + const pct = total > 0 ? Math.round((downloaded / total) * 100) : 0; + const downloadedMb = (downloaded / 1_048_576).toFixed(1); + const totalMb = total > 0 ? (total / 1_048_576).toFixed(1) : '…'; + + return ( +
+

Setting up capture engine…

+ {mitmdumpSetup.phase === 'downloading' && ( + <> + +

+ Downloading mitmdump {downloadedMb} / {totalMb} MB +

+ + )} +
+ ); + } + + if (mitmdumpSetup.phase === 'error') { + return ( +
+

Failed to set up mitmdump

+

{mitmdumpSetup.message}

+ +
+ ); + } + return (
From 3b6b05bf295fcf2a014eed5f6762a5d429d2e4e5 Mon Sep 17 00:00:00 2001 From: Flaykz Date: Tue, 7 Jul 2026 19:29:15 +1100 Subject: [PATCH 7/8] docs: update README for mitmdump auto-download (P7 completed for Linux/Windows) --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a2999dd..35dd634 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ For Java and Spring Boot workflows, see [Spring Boot / JVM Capture](#spring-boot - `Clear traffic` action that clears unpinned in-memory and persisted captures. - SQLite persistence for captures with a bounded retention window. - Fault injection (delay, forced status/body, abort, throttle, streamed-response disconnect) matched by host/method/path/header, optionally applied to a percentage of matching traffic, persisted and broadcast live to every running capture session, with a desktop UI panel to create/toggle/delete rules (see Roadmap P8). +- Automatic mitmdump download on first use (Linux and Windows): if `mitmdump` is not found on PATH, ProcTap downloads the standalone binary from mitmproxy's GitHub releases and caches it locally — no manual installation step required. - Live backend log panel showing recent in-memory ProcTap and `mitmdump` diagnostics, with search and clear actions. ## Roadmap @@ -199,7 +200,7 @@ Roadmap state legend: `[ ]` not started, `[~]` in progress, `[x]` completed. ### P7 - Packaging -- [ ] Investigate bundling mitmproxy as a Tauri sidecar so it is not a separate install step for end users (today: mitmproxy is a documented prerequisite, the same pattern as the JDK prerequisite before it). Researched: mitmproxy ships self-contained single-file binaries for Windows and Linux (a clean fit for Tauri's sidecar mechanism), but macOS ships as a multi-file app bundle that does not map onto a single sidecar binary. Not worth the added release-artifact size and CI complexity for v1; keep mitmproxy as a documented prerequisite for now. +- [x] Automatic mitmdump download on first use (Linux and Windows): ProcTap checks PATH and the local app data cache; if not found, it downloads the mitmproxy standalone binary from GitHub releases with a progress overlay in the UI. macOS is excluded (no single-file binary available from mitmproxy). - [~] Verify the same capture flow on Linux (mitmproxy's `local:` mode also supports Linux via a native, non-eBPF path for this use case). Verified at the `mitmdump`/trust-script level in WSL2 Ubuntu 22.04: targeted-process isolation works identically to Windows, and two real bugs found in that pass are fixed — `crates/mitm_engine` now detects and reports the non-root case instead of hanging (`mitmdump --mode local:` on Linux requires root), and `mitm_engine::trust::install_os_trust`'s Linux path now installs the CA via `update-ca-certificates` when running as root, with a corrected (but still Debian/Ubuntu-unsupported) p11-kit fallback otherwise. Still open: nobody has run the actual ProcTap Tauri app end-to-end on a real Linux desktop (needs a full Linux Tauri toolchain — webkit2gtk etc. — not available in the sessions so far). ### P8 - Fault Injection @@ -223,7 +224,7 @@ Scoped, rule-based fault injection for application testing, in the spirit of Tox - The MVP remains read-only by default and intentionally does not support rewrite, replay, or mocking. Fault injection (P8) is the one carved-out exception, currently engine-only with no UI. - ProcTap is not functional yet; documented workflows describe the intended MVP behavior and may not work end-to-end today. -- mitmproxy is a required external dependency; ProcTap does not bundle it yet. +- mitmproxy is downloaded automatically on first use on Linux and Windows (standalone binary cached in the local app data directory); macOS users still need to install it manually. - Trust automation imports the CA into trust stores it can reach without elevation on Windows and macOS; some clients (custom `SSLContext`, certificate pinning, or already-running processes) may still not decrypt correctly. - **Linux is not fully supported yet.** `mitmdump --mode local:` requires root on Linux, unlike Windows and macOS; ProcTap detects this and reports a clear error instead of hanging, but does not (yet) offer an in-app elevation flow, so capture only works today if ProcTap itself is run as root. OS trust install on Linux also requires root (`update-ca-certificates` on Debian/Ubuntu); there is currently no confirmed no-root trust-install path on that distro family, and Fedora/RHEL-style distros are unverified. Verified so far at the `mitmdump`/trust-script level only — the full Tauri app has not yet been run end-to-end on a real Linux desktop. - Process attribution is PID-based only when a specific instance is picked from the picker's disambiguation list (shown when multiple running processes share a name); capturing by bare name (the common case, more resilient to that process restarting mid-session) leaves `processPid` unset on captured exchanges, since mitmproxy itself never exposes a flow's originating PID. From cc1de0ff09c741ac1ab4b07638eded7313d7ce66 Mon Sep 17 00:00:00 2001 From: Flaykz Date: Tue, 7 Jul 2026 22:52:30 +1100 Subject: [PATCH 8/8] wip --- Cargo.lock | 431 +++++++++++++++++- README.md | 5 +- apps/desktop/src-tauri/src/main.rs | 77 +++- apps/desktop/src/App.tsx | 123 ++++- crates/mitm_engine/src/download.rs | 36 +- crates/mitm_engine/src/lib.rs | 145 ++++-- crates/mitm_engine/src/trust.rs | 61 ++- crates/processes/src/lib.rs | 22 +- docs/live-streaming-bodies.md | 127 ------ ...026-07-07-mitmdump-auto-download-design.md | 142 ------ 10 files changed, 791 insertions(+), 378 deletions(-) delete mode 100644 docs/live-streaming-bodies.md delete mode 100644 docs/superpowers/specs/2026-07-07-mitmdump-auto-download-design.md diff --git a/Cargo.lock b/Cargo.lock index cd2e4e0..b4b6017 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,6 +68,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "atk" version = "0.18.2" @@ -353,6 +362,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + [[package]] name = "chrono" version = "0.4.45" @@ -434,6 +460,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -580,6 +615,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -843,6 +889,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1100,8 +1156,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1123,10 +1181,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1418,6 +1479,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1826,6 +1903,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1847,6 +1930,12 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "markup5ever" version = "0.38.0" @@ -1908,12 +1997,18 @@ dependencies = [ "base64 0.22.1", "capture", "fault_rules", + "flate2", + "futures-util", + "reqwest 0.12.28", "serde", "serde_json", "sha2", + "tar", + "tempfile", "tokio", "uuid", "which", + "zip", ] [[package]] @@ -2517,6 +2612,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.4", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.4", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2538,6 +2689,32 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2633,6 +2810,47 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -2663,10 +2881,24 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -2711,16 +2943,70 @@ dependencies = [ "bitflags 2.13.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", "windows-sys 0.59.0", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2911,6 +3197,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" @@ -2981,7 +3279,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3141,6 +3439,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3272,6 +3576,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3308,7 +3623,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -3491,6 +3806,19 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.0" @@ -3625,6 +3953,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.17" @@ -3921,6 +4259,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4128,6 +4472,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -4163,6 +4520,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.4" @@ -4219,6 +4586,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4263,7 +4639,7 @@ checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" dependencies = [ "either", "home", - "rustix", + "rustix 0.38.44", "winsafe", ] @@ -4851,6 +5227,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "yoke" version = "0.8.3" @@ -4915,6 +5301,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" @@ -4948,8 +5340,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/README.md b/README.md index 35dd634..03b93e7 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ For Java and Spring Boot workflows, see [Spring Boot / JVM Capture](#spring-boot - Optional, independent explicit-proxy mode (toggleable, port-configurable) that runs concurrently with per-process capture, for clients that can't be targeted by process name/PID at all. - One-click launch of a dedicated Chromium browser profile through the explicit proxy, including loopback traffic such as `localhost`. - Automatic CA trust installation into the current user's OS trust store on first capture (for both per-process and explicit-proxy sessions). +- On Linux, per-process capture requests Polkit elevation for the `mitmdump --mode local:` subprocess when ProcTap itself is not already running as root. - Automatic CA trust installation into every discovered JDK truststore when the capture target is a JVM process, or always when the explicit proxy is started (any client could connect to it). - Upstream TLS certificate verification is disabled in mitmproxy so internal services signed by private/corporate CAs can still be captured. - HTTP capture with request headers, request body, response headers, response body, status, duration, and errors. Non-streaming request/response bodies are capped before they cross the mitmproxy bridge to keep the desktop app responsive under bursty browser traffic. @@ -201,7 +202,7 @@ Roadmap state legend: `[ ]` not started, `[~]` in progress, `[x]` completed. ### P7 - Packaging - [x] Automatic mitmdump download on first use (Linux and Windows): ProcTap checks PATH and the local app data cache; if not found, it downloads the mitmproxy standalone binary from GitHub releases with a progress overlay in the UI. macOS is excluded (no single-file binary available from mitmproxy). -- [~] Verify the same capture flow on Linux (mitmproxy's `local:` mode also supports Linux via a native, non-eBPF path for this use case). Verified at the `mitmdump`/trust-script level in WSL2 Ubuntu 22.04: targeted-process isolation works identically to Windows, and two real bugs found in that pass are fixed — `crates/mitm_engine` now detects and reports the non-root case instead of hanging (`mitmdump --mode local:` on Linux requires root), and `mitm_engine::trust::install_os_trust`'s Linux path now installs the CA via `update-ca-certificates` when running as root, with a corrected (but still Debian/Ubuntu-unsupported) p11-kit fallback otherwise. Still open: nobody has run the actual ProcTap Tauri app end-to-end on a real Linux desktop (needs a full Linux Tauri toolchain — webkit2gtk etc. — not available in the sessions so far). +- [~] Verify the same capture flow on Linux (mitmproxy's `local:` mode also supports Linux via a native, non-eBPF path for this use case). Verified at the `mitmdump`/trust-script level in WSL2 Ubuntu 22.04: targeted-process isolation works identically to Windows, and bugs found in that pass are fixed — `crates/mitm_engine` now requests Polkit elevation for the `mitmdump --mode local:` subprocess when ProcTap is not already root, and `mitm_engine::trust::install_os_trust`'s Linux path installs the CA via `update-ca-certificates` when running as root, with a corrected (but still Debian/Ubuntu-unsupported) p11-kit fallback otherwise. Still open: nobody has run the actual ProcTap Tauri app end-to-end on a real Linux desktop (needs a full Linux Tauri toolchain — webkit2gtk etc. — not available in the sessions so far). ### P8 - Fault Injection @@ -226,7 +227,7 @@ Scoped, rule-based fault injection for application testing, in the spirit of Tox - ProcTap is not functional yet; documented workflows describe the intended MVP behavior and may not work end-to-end today. - mitmproxy is downloaded automatically on first use on Linux and Windows (standalone binary cached in the local app data directory); macOS users still need to install it manually. - Trust automation imports the CA into trust stores it can reach without elevation on Windows and macOS; some clients (custom `SSLContext`, certificate pinning, or already-running processes) may still not decrypt correctly. -- **Linux is not fully supported yet.** `mitmdump --mode local:` requires root on Linux, unlike Windows and macOS; ProcTap detects this and reports a clear error instead of hanging, but does not (yet) offer an in-app elevation flow, so capture only works today if ProcTap itself is run as root. OS trust install on Linux also requires root (`update-ca-certificates` on Debian/Ubuntu); there is currently no confirmed no-root trust-install path on that distro family, and Fedora/RHEL-style distros are unverified. Verified so far at the `mitmdump`/trust-script level only — the full Tauri app has not yet been run end-to-end on a real Linux desktop. +- **Linux is not fully supported yet.** `mitmdump --mode local:` requires root on Linux, unlike Windows and macOS; ProcTap now asks Polkit (`pkexec`) to elevate only the `mitmdump` subprocess when needed, but that path still needs a desktop Polkit agent and has not yet been validated end-to-end in a full Tauri Linux desktop session. OS trust install on Linux also requires root (`update-ca-certificates` on Debian/Ubuntu); there is currently no confirmed no-root trust-install path on that distro family, and Fedora/RHEL-style distros are unverified. - Process attribution is PID-based only when a specific instance is picked from the picker's disambiguation list (shown when multiple running processes share a name); capturing by bare name (the common case, more resilient to that process restarting mid-session) leaves `processPid` unset on captured exchanges, since mitmproxy itself never exposes a flow's originating PID. - The explicit-proxy mode has no process attribution at all, by design — any client pointed at it is captured, tagged `External client`, regardless of what process it is. It runs a second, independent `mitmdump` subprocess sharing the same confdir/CA as the picker session; the two are unrelated otherwise (starting/stopping one never affects the other). - When several picker captures are active, ProcTap uses one combined `local:,` mitmproxy session. Mitmproxy still does not expose the originating PID per captured flow in addon APIs, so combined local captures may be labeled with a shared process name instead of an exact PID. Exact PID attribution remains reliable when only one PID-scoped picker capture is active. diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 7783cd3..9ba2fd0 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -53,6 +53,10 @@ struct AppLogEntry { #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct MitmdumpProgress { + phase: String, + version: String, + url: String, + cache_path: String, downloaded: u64, total: u64, } @@ -250,7 +254,10 @@ async fn restart_local_engine( app_logs, "info", "capture", - format!("Restarting local capture engine; previous spec was local:{}.", handle.spec), + format!( + "Restarting local capture engine; previous spec was local:{}.", + handle.spec + ), ); handle.task.abort(); } @@ -265,10 +272,13 @@ async fn restart_local_engine( .map(|pid| pid.to_string()) .unwrap_or_else(|| target.target_name.clone()), mitm_engine::ProxyMode::LocalGroup { spec, .. } => spec.clone(), - mitm_engine::ProxyMode::Regular { .. } => unreachable!("local restart never uses regular mode"), + mitm_engine::ProxyMode::Regular { .. } => { + unreachable!("local restart never uses regular mode") + } }; - let mitmdump_path = mitm_engine::find_mitmdump_with_local(&local_app_dir()).map_err(|error| error.to_string())?; + let mitmdump_path = mitm_engine::find_mitmdump_with_local(&local_app_dir()) + .map_err(|error| error.to_string())?; let data_dir = local_app_dir(); let confdir = data_dir.join("mitm-ca"); std::fs::create_dir_all(&confdir).map_err(|error| error.to_string())?; @@ -305,7 +315,8 @@ async fn restart_local_engine( } }); - *local_engine.lock().expect("local engine lock poisoned") = Some(LocalEngineHandle { spec, task }); + *local_engine.lock().expect("local engine lock poisoned") = + Some(LocalEngineHandle { spec, task }); Ok(()) } @@ -553,7 +564,8 @@ async fn start_explicit_proxy( stop_explicit_proxy_session(&session); - let mitmdump_path = mitm_engine::find_mitmdump_with_local(&local_app_dir()).map_err(|error| error.to_string())?; + let mitmdump_path = mitm_engine::find_mitmdump_with_local(&local_app_dir()) + .map_err(|error| error.to_string())?; let data_dir = local_app_dir(); let confdir = data_dir.join("mitm-ca"); std::fs::create_dir_all(&confdir).map_err(|error| error.to_string())?; @@ -884,18 +896,69 @@ fn broadcast_enabled_rules( async fn ensure_mitmdump(app_handle: tauri::AppHandle) -> Result<(), String> { let local_dir = local_app_dir(); std::fs::create_dir_all(&local_dir).map_err(|e| e.to_string())?; + let download_url = mitm_engine::download::archive_url(); + let cache_path = local_dir + .join(if cfg!(windows) { + "mitmdump.exe" + } else { + "mitmdump" + }) + .display() + .to_string(); + + let emit_setup_progress = |phase: &str, downloaded: u64, total: u64| { + let _ = app_handle.emit( + "mitmdump-progress", + MitmdumpProgress { + phase: phase.to_string(), + version: mitm_engine::download::MITMPROXY_VERSION.to_string(), + url: download_url.clone(), + cache_path: cache_path.clone(), + downloaded, + total, + }, + ); + }; + + emit_setup_progress("checking", 0, 0); if mitm_engine::find_mitmdump_with_local(&local_dir).is_ok() { + emit_setup_progress("ready", 0, 0); return Ok(()); } - let handle = app_handle.clone(); + emit_setup_progress("downloading", 0, 0); + let progress_handle = app_handle.clone(); + let progress_url = download_url.clone(); + let progress_cache_path = cache_path.clone(); mitm_engine::download::download_mitmdump(&local_dir, move |downloaded, total| { - let _ = handle.emit("mitmdump-progress", MitmdumpProgress { downloaded, total }); + let _ = progress_handle.emit( + "mitmdump-progress", + MitmdumpProgress { + phase: "downloading".to_string(), + version: mitm_engine::download::MITMPROXY_VERSION.to_string(), + url: progress_url.clone(), + cache_path: progress_cache_path.clone(), + downloaded, + total, + }, + ); }) .await .map_err(|e| e.to_string())?; + let _ = app_handle.emit( + "mitmdump-progress", + MitmdumpProgress { + phase: "ready".to_string(), + version: mitm_engine::download::MITMPROXY_VERSION.to_string(), + url: download_url, + cache_path, + downloaded: 0, + total: 0, + }, + ); + Ok(()) } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 15a1cf5..552b042 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -44,11 +44,38 @@ type CaStatus = { }; type MitmdumpSetupState = - | { phase: 'checking' } - | { phase: 'downloading'; downloaded: number; total: number } + | { phase: 'checking'; version?: string; url?: string; cachePath?: string } + | { phase: 'downloading'; downloaded: number; total: number; version?: string; url?: string; cachePath?: string } | { phase: 'ready' } | { phase: 'error'; message: string }; +type MitmdumpProgressEvent = { + phase: 'checking' | 'downloading' | 'ready'; + version: string; + url: string; + cachePath: string; + downloaded: number; + total: number; +}; + +function captureStartErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + const normalized = message.toLowerCase(); + + if ( + normalized.includes("local capture mode needs root on linux") || + normalized.includes("failed to elevate privileges") || + normalized.includes("polkit elevation for linux local capture") + ) { + return [ + 'Linux per-process capture requires root because mitmproxy local mode redirects traffic at the OS level.', + 'Approve the Polkit prompt to continue. If no prompt appears, install polkit/pkexec or launch ProcTap with sudo -E. You can also use Explicit proxy on 127.0.0.1:8877 and configure the client proxy instead.', + ].join('\n'); + } + + return message; +} + type AppLogEntry = { id: number; timestampMs: number; @@ -481,29 +508,60 @@ export function App() { } useEffect(() => { + let cancelled = false; let unlistenProgress: (() => void) | undefined; - (async () => { - unlistenProgress = await listen<{ downloaded: number; total: number }>( - 'mitmdump-progress', - (event) => { - setMitmdumpSetup({ - phase: 'downloading', - downloaded: event.payload.downloaded, - total: event.payload.total, - }); - }, - ); + void listen('mitmdump-progress', (event) => { + if (event.payload.phase === 'ready') { + setMitmdumpSetup({ phase: 'ready' }); + return; + } + + if (event.payload.phase === 'checking') { + setMitmdumpSetup({ + phase: 'checking', + version: event.payload.version, + url: event.payload.url, + cachePath: event.payload.cachePath, + }); + return; + } + setMitmdumpSetup({ + phase: 'downloading', + downloaded: event.payload.downloaded, + total: event.payload.total, + version: event.payload.version, + url: event.payload.url, + cachePath: event.payload.cachePath, + }); + }) + .then((unlisten) => { + if (cancelled) { + unlisten(); + } else { + unlistenProgress = unlisten; + } + }) + .catch(() => { + // Progress updates are best-effort; setup itself should still run. + }); + + (async () => { try { await invoke('ensure_mitmdump'); - setMitmdumpSetup({ phase: 'ready' }); + if (!cancelled) { + setMitmdumpSetup({ phase: 'ready' }); + } } catch (err) { - setMitmdumpSetup({ phase: 'error', message: String(err) }); + if (!cancelled) { + setMitmdumpSetup({ phase: 'error', message: String(err) }); + } } })(); return () => { + cancelled = true; unlistenProgress?.(); }; }, []); @@ -746,7 +804,7 @@ export function App() { setCaptureTargetInput(''); setSelectedPids([]); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setError(captureStartErrorMessage(err)); } finally { setIsAddingCapture(false); } @@ -943,18 +1001,35 @@ export function App() { const pct = total > 0 ? Math.round((downloaded / total) * 100) : 0; const downloadedMb = (downloaded / 1_048_576).toFixed(1); const totalMb = total > 0 ? (total / 1_048_576).toFixed(1) : '…'; + const setupVerb = mitmdumpSetup.phase === 'checking' ? 'Checking for' : 'Downloading'; + const setupTarget = `mitmdump${mitmdumpSetup.version ? ` ${mitmdumpSetup.version}` : ''}`; return ( -
+

Setting up capture engine…

- {mitmdumpSetup.phase === 'downloading' && ( - <> - -

- Downloading mitmdump {downloadedMb} / {totalMb} MB -

- +

+ {setupVerb} {setupTarget} +

+ {mitmdumpSetup.url && ( +

+ Source: {mitmdumpSetup.url} +

+ )} + {mitmdumpSetup.cachePath && ( +

+ Cache: {mitmdumpSetup.cachePath} +

)} + 0 ? pct : undefined} + max={100} + style={{ width: '280px' }} + /> +

+ {mitmdumpSetup.phase === 'downloading' + ? `Downloaded ${downloadedMb} / ${totalMb} MB${total > 0 ? ` (${pct}%)` : ''}` + : 'Looking on PATH and in the local app cache'} +

); } diff --git a/crates/mitm_engine/src/download.rs b/crates/mitm_engine/src/download.rs index 16d3d69..f2467de 100644 --- a/crates/mitm_engine/src/download.rs +++ b/crates/mitm_engine/src/download.rs @@ -1,17 +1,20 @@ use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; +use std::time::Duration; pub const MITMPROXY_VERSION: &str = "11.1.3"; +const DOWNLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(15 * 60); -fn archive_url() -> String { +pub fn archive_url() -> String { if cfg!(windows) { format!( - "https://github.com/mitmproxy/mitmproxy/releases/download/v{v}/mitmproxy-{v}-windows-x86_64.zip", + "https://downloads.mitmproxy.org/{v}/mitmproxy-{v}-windows-x86_64.zip", v = MITMPROXY_VERSION ) } else { format!( - "https://github.com/mitmproxy/mitmproxy/releases/download/v{v}/mitmproxy-{v}-linux-x86_64.tar.gz", + "https://downloads.mitmproxy.org/{v}/mitmproxy-{v}-linux-x86_64.tar.gz", v = MITMPROXY_VERSION ) } @@ -21,14 +24,33 @@ pub async fn download_mitmdump( dest_dir: &Path, on_progress: impl Fn(u64, u64) + Send + Sync + 'static, ) -> Result { - let exe_name = if cfg!(windows) { "mitmdump.exe" } else { "mitmdump" }; + let exe_name = if cfg!(windows) { + "mitmdump.exe" + } else { + "mitmdump" + }; let dest = dest_dir.join(exe_name); - let tmp = dest_dir.join(if cfg!(windows) { "mitmdump.exe.tmp" } else { "mitmdump.tmp" }); + let tmp = dest_dir.join(if cfg!(windows) { + "mitmdump.exe.tmp" + } else { + "mitmdump.tmp" + }); let url = archive_url(); - let response = reqwest::get(&url) + let client = reqwest::Client::builder() + .connect_timeout(DOWNLOAD_CONNECT_TIMEOUT) + .timeout(DOWNLOAD_TIMEOUT) + .build() + .context("failed to create mitmdump download client")?; + + let response = client + .get(&url) + .send() .await .with_context(|| format!("failed to fetch {url}"))?; + let response = response + .error_for_status() + .with_context(|| format!("failed to download mitmdump from {url}"))?; let total = response.content_length().unwrap_or(0); let mut downloaded = 0u64; @@ -105,6 +127,6 @@ mod tests { fn archive_url_contains_version() { let url = archive_url(); assert!(url.contains(MITMPROXY_VERSION)); - assert!(url.starts_with("https://github.com/mitmproxy/mitmproxy/releases/download/")); + assert!(url.starts_with("https://downloads.mitmproxy.org/")); } } diff --git a/crates/mitm_engine/src/lib.rs b/crates/mitm_engine/src/lib.rs index 9f3e4f0..222799e 100644 --- a/crates/mitm_engine/src/lib.rs +++ b/crates/mitm_engine/src/lib.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; use std::process::Stdio; use std::sync::Arc; use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader}; -use tokio::net::TcpListener; +use tokio::net::{TcpListener, TcpStream}; use tokio::process::{Child, Command}; use tokio::sync::watch; use uuid::Uuid; @@ -26,7 +26,7 @@ const MAX_STREAM_BODY_BYTES: usize = 256 * 1024; /// A running mitmproxy engine capturing traffic for a single target process. pub struct MitmEngine { child: Child, - listener: TcpListener, + bridge_stream: TcpStream, log_sink: Option, } @@ -223,14 +223,6 @@ impl MitmEngine { mode: &ProxyMode, log_sink: Option, ) -> Result { - // Only `local:` mode redirects traffic at the OS level (WinDivert/cgroup), - // which is what needs root on Linux. `regular` mode is a plain listening - // socket, same privilege requirements as any other server on a normal port. - #[cfg(target_os = "linux")] - if matches!(mode, ProxyMode::Local(_) | ProxyMode::LocalGroup { .. }) { - ensure_linux_capture_privileges()?; - } - tokio::fs::write(addon_path, BRIDGE_ADDON_SOURCE) .await .with_context(|| format!("failed to write bridge addon to {}", addon_path.display()))?; @@ -246,7 +238,17 @@ impl MitmEngine { addon_path.display() ); - let mut child = Command::new(mitmdump_path) + let elevate_with_pkexec = should_elevate_mitmdump(mode); + if elevate_with_pkexec { + let message = "Linux local capture needs root; requesting Polkit elevation for mitmdump via pkexec.".to_string(); + eprintln!("mitm_engine: {message}"); + if let Some(log_sink) = &log_sink { + log_sink("info", message); + } + } + + let mut command = mitmdump_command(mitmdump_path, bridge_port, elevate_with_pkexec); + let mut child = command .arg("--mode") .arg(mode.mode_arg()) .arg("--scripts") @@ -255,12 +257,17 @@ impl MitmEngine { .arg(format!("confdir={}", confdir.display())) .arg("--set") .arg("ssl_insecure=true") - .env("PROCTAP_BRIDGE_PORT", bridge_port.to_string()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true) .spawn() - .context("failed to spawn mitmdump")?; + .with_context(|| { + if elevate_with_pkexec { + "failed to spawn mitmdump through pkexec; install polkit/pkexec or run ProcTap as root".to_string() + } else { + "failed to spawn mitmdump".to_string() + } + })?; if let Some(stdout) = child.stdout.take() { tokio::spawn(forward_child_output( @@ -277,9 +284,24 @@ impl MitmEngine { )); } + let (bridge_stream, _) = tokio::select! { + accepted = listener.accept() => accepted.context( + "failed to accept bridge addon connection from mitmdump", + )?, + status = child.wait() => { + let status = status.context("failed to wait on mitmdump")?; + if elevate_with_pkexec { + anyhow::bail!( + "Polkit elevation for Linux local capture was denied or failed; mitmdump exited before the bridge addon connected ({status})" + ); + } + anyhow::bail!("mitmdump exited before the bridge addon connected ({status})"); + } + }; + Ok(Self { child, - listener, + bridge_stream, log_sink, }) } @@ -305,10 +327,8 @@ impl MitmEngine { mut on_exchange: impl FnMut(CapturedExchange), mut rules: watch::Receiver>, ) -> Result<()> { - let (stream, _) = self.listener.accept().await.context( - "bridge addon never connected back to mitm_engine (is mitmdump running with bridge_addon.py loaded?)", - )?; - let (read_half, mut write_half) = stream.into_split(); + let log_sink = self.log_sink.clone(); + let (read_half, mut write_half) = self.bridge_stream.into_split(); let mut lines = BufReader::new(read_half).lines(); let mut streaming_exchanges = HashMap::::new(); @@ -328,7 +348,7 @@ impl MitmEngine { on_exchange(exchange); } } - Err(e) => self.log_engine("warn", format!("dropping malformed flow line: {e}")), + Err(e) => log_engine(&log_sink, "warn", format!("dropping malformed flow line: {e}")), } } Some(_) => {} @@ -354,12 +374,12 @@ impl MitmEngine { } } } +} - fn log_engine(&self, level: &'static str, message: String) { - eprintln!("mitm_engine: {message}"); - if let Some(log_sink) = &self.log_sink { - log_sink(level, message); - } +fn log_engine(log_sink: &Option, level: &'static str, message: String) { + eprintln!("mitm_engine: {message}"); + if let Some(log_sink) = log_sink { + log_sink(level, message); } } @@ -588,30 +608,57 @@ fn decode_body(body_b64: Option) -> Option> { body_b64.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok()) } +fn should_elevate_mitmdump(mode: &ProxyMode) -> bool { + linux_local_capture_needs_elevation(mode) +} + +#[cfg(target_os = "linux")] +fn linux_local_capture_needs_elevation(mode: &ProxyMode) -> bool { + matches!(mode, ProxyMode::Local(_) | ProxyMode::LocalGroup { .. }) && !is_linux_root() +} + +#[cfg(not(target_os = "linux"))] +fn linux_local_capture_needs_elevation(_mode: &ProxyMode) -> bool { + false +} + +fn mitmdump_command( + mitmdump_path: &std::path::Path, + bridge_port: u16, + elevate_with_pkexec: bool, +) -> Command { + if elevate_with_pkexec { + mitmdump_pkexec_command(mitmdump_path, bridge_port) + } else { + let mut command = Command::new(mitmdump_path); + command.env("PROCTAP_BRIDGE_PORT", bridge_port.to_string()); + command + } +} + /// On Linux, `mitmdump --mode local:` redirects traffic at the OS level and -/// needs root: internally it re-execs itself under `sudo` when not already root. In a -/// desktop app that subprocess has no controlling terminal, so `sudo` can't prompt and -/// mitmdump just fails fast with "Failed to elevate privileges" — surfaced today as an -/// opaque "mitmdump exited early" error well after the fact. Checking `euid` up front -/// turns that into an immediate, actionable error instead. +/// needs root. In a desktop app, mitmproxy's internal `sudo` fallback has no +/// controlling terminal, so ProcTap launches mitmdump through Polkit instead. /// /// Granting the mitmdump binary `CAP_NET_ADMIN`/`CAP_NET_RAW` via `setcap` as a /// root-free alternative was tried and does not work with mitmproxy's official /// standalone (PyInstaller onefile) binaries: file capabilities put the binary in /// secure-exec mode, which makes the dynamic loader ignore `LD_LIBRARY_PATH` and breaks /// its bundled `libssl.so.1.1` loading (verified: `ImportError: libssl.so.1.1: cannot -/// open shared object file`). Root is the only currently-known working path here. +/// open shared object file`). Elevation is the only currently-known working path here. #[cfg(target_os = "linux")] -fn ensure_linux_capture_privileges() -> Result<()> { - anyhow::ensure!( - is_linux_root(), - "mitmdump's local capture mode needs root on Linux: it redirects traffic at the OS \ - level and elevates itself via `sudo`, which fails without a terminal to prompt on. \ - Run ProcTap itself as root (e.g. launch it with `sudo -E`), or start it via `pkexec`. \ - Granting the mitmdump binary CAP_NET_ADMIN/CAP_NET_RAW via `setcap` does not work \ - around this: it breaks mitmproxy's official standalone binary (verified)." - ); - Ok(()) +fn mitmdump_pkexec_command(mitmdump_path: &std::path::Path, bridge_port: u16) -> Command { + let mut command = Command::new("pkexec"); + command + .arg("env") + .arg(format!("PROCTAP_BRIDGE_PORT={bridge_port}")) + .arg(mitmdump_path); + command +} + +#[cfg(not(target_os = "linux"))] +fn mitmdump_pkexec_command(_mitmdump_path: &std::path::Path, _bridge_port: u16) -> Command { + unreachable!("pkexec elevation is only used for Linux local capture") } /// Whether the current process is running as root, by reading `/proc/self/status` @@ -655,7 +702,11 @@ pub fn find_mitmdump_with_local(local_dir: &std::path::Path) -> Result Result { } } - // Verified on Ubuntu 22.04: `trust anchor --store` fails there with - // "no configured writable location to store anchors" — Debian/Ubuntu - // don't wire up p11-kit's anchor mechanism the way Fedora/RHEL do, and - // there is no other no-root, no-prompt system-wide trust install on - // that distro family. Root is the only reliable path, so report exactly - // what to run instead of pretending this succeeded. + // On desktop Linux, Polkit can provide the same kind of elevation prompt + // users expect from Windows UAC. Prefer that before falling back to a + // manual terminal command. + match install_linux_system_trust_with_pkexec(cert_path) { + Ok(message) => return Ok(message), + Err(pkexec_error) => { + bail!( + "Could not install the CA into the OS trust store without root (p11-kit has no \ + writable per-user anchor location on this system), and the Polkit elevation \ + attempt failed: {pkexec_error}. Run this manually:\n {manual}", + manual = linux_manual_trust_command(cert_path) + ); + } + } + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn linux_manual_trust_command(cert_path: &Path) -> String { + format!( + "sudo cp {} /usr/local/share/ca-certificates/proctap-mitmproxy-ca.crt && sudo update-ca-certificates", + shell_quote(&cert_path.display().to_string()) + ) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn install_linux_system_trust_with_pkexec(cert_path: &Path) -> Result { + let script = "cp \"$1\" /usr/local/share/ca-certificates/proctap-mitmproxy-ca.crt && update-ca-certificates"; + let output = Command::new("pkexec") + .args(["sh", "-c", script, "proctap-trust-install"]) + .arg(cert_path) + .output() + .context("failed to run pkexec; install policykit-1/polkit or run the manual command")?; + + if output.status.success() { + Ok("Installed CA into OS trust store (system-wide, via Polkit).".to_string()) + } else { bail!( - "Could not install the CA into the OS trust store without root (p11-kit has no \ - writable per-user anchor location on this system). Run ProcTap as root to install \ - it automatically, or run this manually:\n sudo cp {cert} \ - /usr/local/share/ca-certificates/proctap-mitmproxy-ca.crt && sudo update-ca-certificates", - cert = cert_path.display() + "pkexec failed ({}): {}{}", + output.status, + String::from_utf8_lossy(&output.stderr).trim(), + if output.stderr.is_empty() { + String::new() + } else { + "\n".to_string() + } ); } } +#[cfg(all(unix, not(target_os = "macos")))] +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + /// Installs `cert_path` system-wide via `update-ca-certificates`, the standard /// Debian/Ubuntu mechanism (root-only). Verified live: after running this, a /// non-root `curl` trusts a leaf certificate signed by the installed CA with no diff --git a/crates/processes/src/lib.rs b/crates/processes/src/lib.rs index c2ec986..1b3407b 100644 --- a/crates/processes/src/lib.rs +++ b/crates/processes/src/lib.rs @@ -54,20 +54,14 @@ pub fn list_processes() -> Result> { .iter() .map(|(pid, process)| { let name = process.name().to_string(); - let command_parts = process - .cmd() - .iter() - .cloned() - .collect::>(); + let command_parts = process.cmd().iter().cloned().collect::>(); let command = command_parts.join(" "); let searchable = format!("{name} {command}").to_ascii_lowercase(); let is_java = is_java_process(&searchable); let java_artifact_name = is_java .then(|| java_artifact_name(&command_parts)) .flatten(); - let java_main_class = is_java - .then(|| java_main_class(&command_parts)) - .flatten(); + let java_main_class = is_java.then(|| java_main_class(&command_parts)).flatten(); let pid = pid.as_u32(); let ancestor_chain = process_ancestor_chain(&system, process.parent()); let parent_pid = ancestor_chain.first().map(|parent| parent.pid); @@ -141,7 +135,11 @@ fn java_artifact_name(command_parts: &[String]) -> Option { .find(|window| window[0] == "-jar") .and_then(|window| artifact_basename(&window[1])); - artifact_after_jar.or_else(|| command_parts.iter().find_map(|part| artifact_basename(part))) + artifact_after_jar.or_else(|| { + command_parts + .iter() + .find_map(|part| artifact_basename(part)) + }) } fn artifact_basename(raw: &str) -> Option { @@ -205,7 +203,11 @@ mod tests { #[test] fn falls_back_to_first_artifact_argument() { - let command = vec!["java".into(), "-Dloader.path=lib".into(), "service.jar".into()]; + let command = vec![ + "java".into(), + "-Dloader.path=lib".into(), + "service.jar".into(), + ]; assert_eq!(java_artifact_name(&command), Some("service.jar".into())); } diff --git a/docs/live-streaming-bodies.md b/docs/live-streaming-bodies.md deleted file mode 100644 index 2e4d011..0000000 --- a/docs/live-streaming-bodies.md +++ /dev/null @@ -1,127 +0,0 @@ -# Faire vivre les corps en streaming (SSE, chunked long-lived) - -## Contexte / constat - -Aujourd'hui, un flow n'est envoyé qu'une seule fois, en entier, quand mitmproxy -a fini de le bufferiser (`bridge_addon.py`'s `response()` hook, qui appelle -`_send(self._serialize(flow))`). Pour une réponse `text/event-stream` (SSE) ou -tout flux long-lived : - -- Si le flux finit par se fermer, l'exchange n'apparait dans ProcTap qu'*après - coup*, corps entier concaténé (pas d'affichage évènement par évènement en - direct). -- Si le flux ne se ferme jamais tant que l'app tourne, l'exchange n'apparait - **jamais**. -- Aucune limite de taille n'est appliquée au corps capturé : un stream long - avant fermeture peut consommer beaucoup de mémoire (déjà noté comme lacune - connue dans `README.md`, roadmap : "Improve handling for large or streaming - bodies with safe memory limits"). - -Le but de ce ticket : permettre d'observer un flow SSE (ou tout stream) au fil -de l'eau dans l'UI desktop, pas seulement une fois terminé. - -## Pourquoi ce n'est pas trivial - -Toute la chaine est conçue pour "un message JSON immuable par flow terminé, -une seule fois" : - -- Socket bridge (Python <-> Rust) : une ligne JSON = un flow complet. -- `mitm_engine::WireExchange` : struct plate, pas de notion de "flow en - cours", pas de champ id transmis par Python. -- `capture::push_capture` : `captures.push(exchange)` uniquement, jamais de - update in-place d'un exchange existant. -- `storage::insert_exchange` : `INSERT OR IGNORE`, pas de chemin `UPDATE`. -- Frontend : poll (`setInterval` 1.5s dans `App.tsx`) qui refetch un snapshot - complet — ça, en revanche, marche déjà "gratuitement" si le backend fait de - l'upsert par id (pas besoin de websocket/SSE côté UI). - -## Ce qu'il faudrait changer, couche par couche - -### 1. `crates/mitm_engine/src/bridge_addon.py` - -- Détecter les réponses à streamer (typiquement dans un hook - `responseheaders()` : `content-type: text/event-stream`, ou - `transfer-encoding: chunked` sans `content-length`) et activer - `flow.response.stream = ` pour ces flows. -- Le callback reçoit les chunks bruts au fur et à mesure — c'est le point - d'accroche pour émettre des messages incrémentaux sur le socket bridge. -- **Piège** : une fois `stream` activé sur un flow, `flow.response.raw_content` - n'est plus disponible dans le hook `response()` final (le corps n'est jamais - bufferisé côté mitmproxy). Ça casse en l'état : - - `_serialize()` (lit `response.raw_content` pour `response_body_b64`) ; - - l'action de fault injection `throttle` (lit aussi `raw_content`, ligne - ~121) — il faudra soit l'exclure pour les flows streamés, soit - l'implémenter différemment (délai par chunk plutôt que délai global sur - tout le corps). - -### 2. Protocole du socket bridge - -- Remplacer "un objet JSON = un flow terminé" par une enveloppe à plusieurs - types de messages, par exemple : `started` (id stable + headers connus dès - le début), `chunk` (id, bytes/b64, seq), `done` (id, timing final, status si - connu tardivement). -- Il faut un id stable partagé entre Python et Rust dès le *début* du flow. - mitmproxy expose déjà `flow.id`, mais il n'est utilisé nulle part - aujourd'hui — `mitm_engine` ne génère un `Uuid` qu'à la réception de l'objet - complet. Il faudra probablement réutiliser `flow.id` (ou le faire - correspondre à un `Uuid` généré côté Rust et renvoyé à l'addon via le canal - de règles déjà bidirectionnel). - -### 3. `crates/mitm_engine/src/lib.rs` - -- `WireExchange` (struct unique, tout-ou-rien) devient un enum d'évènements à - désérialiser (`Started` / `Chunk` / `Done`). -- La boucle `run_with_fault_rules` doit distinguer "nouvel exchange" (push) - de "chunk reçu pour un exchange existant" (update), au lieu d'appeler - `on_exchange` une fois par ligne reçue sans distinction. - -### 4. `crates/capture` (store en mémoire) - -- `push_capture` (actuellement `captures.push(exchange)` seulement) a besoin - d'un pendant `push_or_update_capture(id, patch)` qui retrouve l'entrée - existante par id et append au `response_body`, avec un flag genre - `streaming: bool` pour que l'UI sache que ce n'est pas terminé. -- Revoir la limite "500 exchanges" (troncature par `remove(0)`) et ajouter une - limite de taille de corps pour les flows en streaming (troncature avec - indicateur "tronqué" plutôt que croissance illimitée) — c'est l'item - roadmap déjà identifié dans `README.md`. - -### 5. `crates/storage` (SQLite) - -- `insert_exchange` fait `INSERT OR IGNORE` : pas de chemin d'update. - Deux options : - - écrire une seule fois à la fin (le live reste un phénomène purement en - mémoire, jamais persisté avant complétion) — plus simple, perd l'historique - d'un stream si l'app crashe en cours de route ; - - ajouter un `UPDATE ... SET response_body = ?, status = ? WHERE id = ?` - appelé à chaque chunk (ou périodiquement, pas à chaque évènement SSE - individuel, pour limiter le coût I/O). - -### 6. Frontend (`apps/desktop/src/App.tsx`) - -- Le modèle de poll existant (toutes les 1.5s) fonctionne déjà tel quel *si* - le backend fait de l'upsert par id : chaque poll renverrait juste un corps - plus long pour la même entrée, sans rien changer côté React/Tauri command. -- Seul ajout utile : un badge "live/streaming" tant que `streaming: true`, - pour distinguer visuellement un exchange en cours d'un exchange figé. - `BodyViewer` (ligne ~1488) affiche déjà le corps en texte brut, ça marche - tel quel pour du contenu qui grandit. - -## Résumé - -Le point dur n'est ni l'UI (elle suit "gratuitement" via le poll existant) ni -vraiment l'API mitmproxy (le mode streaming existe déjà), c'est de faire muter -tout le pipeline vers un modèle "id stable + upsert incrémental" de bout en -bout : `bridge_addon.py` -> socket bridge -> `mitm_engine` -> `capture` store --> SQLite. Il faut aussi décider une politique de troncature mémoire pour les -flows qui ne se ferment jamais, sinon on rouvre le point déjà noté en -Known Limitations / roadmap du README. - -## Portée suggérée pour un premier incrément - -Ne pas tout faire d'un coup : possible V1 raisonnable = SSE uniquement (pas -tout chunked générique), pas de persistance incrémentale en base (uniquement -en mémoire tant que le flow est en cours, écriture SQLite finale une fois -`done`), troncature dure du corps affiché en direct (ex: garder les N derniers -Ko). Ça couvre le cas d'usage principal (voir les events SSE arriver) sans -toucher au throttle/fault-injection ni à `storage`. diff --git a/docs/superpowers/specs/2026-07-07-mitmdump-auto-download-design.md b/docs/superpowers/specs/2026-07-07-mitmdump-auto-download-design.md deleted file mode 100644 index bf1fe64..0000000 --- a/docs/superpowers/specs/2026-07-07-mitmdump-auto-download-design.md +++ /dev/null @@ -1,142 +0,0 @@ -# mitmdump Auto-Download Design - -**Date:** 2026-07-07 -**Scope:** Linux and Windows only (macOS excluded — no single-file standalone binary) -**Roadmap:** P7 - -## Problem - -When the user activates the proxy (start capture or explicit proxy), ProcTap calls -`find_mitmdump()` which looks for `mitmdump` on PATH. If absent, the Tauri command -returns an error string to the UI — the user sees a raw error and has no path forward. - -## Goal - -On first use, if `mitmdump` is not already installed, ProcTap downloads and caches the -standalone binary automatically, showing a progress indicator. The user never has to -install anything manually. - -## User Experience - -1. User starts the app. -2. On mount, the frontend calls the new `ensure_mitmdump` Tauri command. -3. If mitmdump is already available (PATH or local cache): command returns immediately, - normal UI appears. -4. If not: a full-screen setup overlay appears — "Setting up capture engine…" — with a - progress bar fed by `mitmdump-progress` events. -5. Once the download and extraction complete, the overlay dismisses and the normal UI - renders. The user clicks Start capture as usual. -6. On all subsequent launches the cached binary is found immediately; the overlay never - appears again. - -## Architecture - -### mitmdump Resolution Order (extended `find_mitmdump`) - -1. `PROCTAP_MITMDUMP` environment variable (existing) -2. `mitmdump` / `mitmdump.exe` on PATH (existing) -3. `local_app_dir()/mitmdump[.exe]` — the auto-download cache (new) - -### New: `crates/mitm_engine/src/download.rs` - -Public async function: - -```rust -pub async fn download_mitmdump( - dest_dir: &Path, - on_progress: impl Fn(u64, u64) + Send + Sync + 'static, -) -> Result -``` - -Behaviour: -- Target version: **11.1.3** (hardcoded constant `MITMPROXY_VERSION`) -- Linux URL: `https://github.com/mitmproxy/mitmproxy/releases/download/v{VERSION}/mitmproxy-{VERSION}-linux-x86_64.tar.gz` -- Windows URL: `https://github.com/mitmproxy/mitmproxy/releases/download/v{VERSION}/mitmproxy-{VERSION}-windows-x86_64.zip` -- Streams the download with `reqwest` (feature `stream`), calling `on_progress(downloaded, total)` per chunk -- Extracts only `mitmdump` / `mitmdump.exe` from the archive (skips everything else) - - Linux: `flate2` + `tar` crate to walk `.tar.gz` entries - - Windows: `zip` crate to walk `.zip` entries -- On Linux: sets the extracted file executable (`chmod +x` via `std::fs::Permissions`) -- Writes the binary to `dest_dir/mitmdump[.exe]` -- Returns the final path - -New dependencies for `crates/mitm_engine`: -- `reqwest` with `{ version, features = ["stream"] }` -- `flate2` -- `tar` -- `zip` (unconditional — simpler than cfg-gating) -- `tokio` already present - -### Modified: `find_mitmdump` in `crates/mitm_engine/src/lib.rs` - -Add a new `find_mitmdump_with_local(local_dir: &Path) -> Result` alongside the -existing `find_mitmdump()`. The new function adds step 3 (local cache lookup) after the -PATH search. The existing `find_mitmdump()` delegates to `find_mitmdump_with_local` with -a dummy/empty path so existing call sites in tests and the spike binary compile unchanged. - -### New Tauri Command: `ensure_mitmdump` - -In `apps/desktop/src-tauri/src/main.rs`: - -```rust -#[tauri::command] -async fn ensure_mitmdump(app_handle: tauri::AppHandle) -> Result<(), String> -``` - -Logic: -1. Call `find_mitmdump_with_local(&local_app_dir())` — if found, return `Ok(())`. -2. If not found, call `download_mitmdump(&local_app_dir(), |downloaded, total| { ... })`. -3. Inside the closure, emit a `mitmdump-progress` event: - `app_handle.emit("mitmdump-progress", MitmdumpProgress { downloaded, total })` -4. On success, return `Ok(())`. On error, return `Err(error.to_string())`. - -Event payload type: - -```rust -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct MitmdumpProgress { downloaded: u64, total: u64 } -``` - -### Modified: `start_capture` and `start_explicit_proxy` - -Replace the inline `find_mitmdump()` call with `find_mitmdump_with_local(&local_app_dir())` -so they use the cached binary if the frontend somehow called start before ensure. - -### Frontend: `App.tsx` - -New state: - -```ts -type MitmdumpSetupState = - | { phase: 'checking' } - | { phase: 'downloading'; downloaded: number; total: number } - | { phase: 'ready' } - | { phase: 'error'; message: string }; -``` - -On mount: -1. Set state to `{ phase: 'checking' }`. -2. Listen to `mitmdump-progress` events — update state to `{ phase: 'downloading', ... }`. -3. `invoke('ensure_mitmdump')` — on resolve set `{ phase: 'ready' }`, on reject set `{ phase: 'error', message }`. - -Render: -- `checking` / `downloading`: full-screen centered overlay, title "Setting up capture engine", - subtitle "Downloading mitmdump (X / Y MB)…", `` element. -- `error`: overlay with error text and a Retry button. -- `ready`: normal app UI. - -## Error Handling - -- Network error during download: surface message in the error overlay with a Retry button - (re-invokes `ensure_mitmdump`). -- Partial download left on disk: `download_mitmdump` writes to a `.tmp` path and renames - atomically on success, so a crash mid-download never leaves a corrupt binary. -- Extraction fails (e.g. unexpected archive layout from a future mitmproxy release): - return error — user sees the error overlay. - -## What Is NOT in Scope - -- macOS support (no single-file binary available from mitmproxy). -- Automatic version upgrades (version is hardcoded; the user can delete the cache to force a re-download). -- Download progress for the existing `start_capture` path when called before `ensure_mitmdump`.