commit 2dd8be72caa16f8ebc26793784299796c04c30d2 Author: therealaleph Date: Tue Apr 21 18:03:03 2026 +0300 initial release: Rust port of MasterHttpRelayVPN apps_script mode Faithful port of @masterking32's MasterHttpRelayVPN. All credit for the original idea, protocol, and Python implementation goes to him. Implemented: - Local HTTP proxy (CONNECT + plain HTTP) - MITM with on-the-fly per-domain cert generation via rcgen - CA auto-install for macOS / Linux / Windows - Apps Script JSON relay, protocol-compatible with Code.gs - TLS client with SNI spoofing (connect to Google IP, SNI=www.google.com, inner HTTP Host=script.google.com) - Connection pooling (45s TTL, max 20 idle) - Multi-script round-robin for higher quota - Header filtering (strips connection-specific + brotli) - Config-driven, JSON schema matches Python version Deferred (TODOs in code): - HTTP/2 multiplexing - Request batching / coalescing / response cache - Range-based parallel download - SNI-rewrite tunnels for YouTube/googlevideo - Firefox NSS cert install - domain_fronting / google_fronting / custom_domain modes (mostly broken post-Cloudflare 2024, not a priority) 13 unit tests pass, 2.4MB stripped release binary. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..31d9d69 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,108 @@ +name: release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build: + strategy: + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + name: mhrv-rs-linux-amd64 + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + name: mhrv-rs-linux-arm64 + - target: x86_64-apple-darwin + os: macos-latest + name: mhrv-rs-macos-amd64 + - target: aarch64-apple-darwin + os: macos-latest + name: mhrv-rs-macos-arm64 + - target: x86_64-pc-windows-gnu + os: windows-latest + name: mhrv-rs-windows-amd64 + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install MinGW toolchain + if: matrix.target == 'x86_64-pc-windows-gnu' + id: msys2 + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + update: true + install: mingw-w64-x86_64-gcc + + - name: Install cross-compilation tools + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + echo '[target.aarch64-unknown-linux-gnu]' >> ~/.cargo/config.toml + echo 'linker = "aarch64-linux-gnu-gcc"' >> ~/.cargo/config.toml + + - name: Configure GNU linker + if: matrix.target == 'x86_64-pc-windows-gnu' + shell: pwsh + run: | + $gcc = "${{ steps.msys2.outputs.msys2-location }}\mingw64\bin\gcc.exe" -replace '\\','/' + New-Item -ItemType Directory -Force -Path $env:USERPROFILE/.cargo | Out-Null + Add-Content -Path $env:USERPROFILE/.cargo/config.toml -Value '[target.x86_64-pc-windows-gnu]' + Add-Content -Path $env:USERPROFILE/.cargo/config.toml -Value "linker = '$gcc'" + + - name: Build + run: cargo build --release --target ${{ matrix.target }} + + - name: Package (unix) + if: runner.os != 'Windows' + run: | + mkdir -p dist + cp target/${{ matrix.target }}/release/mhrv-rs dist/${{ matrix.name }} + chmod +x dist/${{ matrix.name }} + + - name: Package (windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path dist + Copy-Item target/${{ matrix.target }}/release/mhrv-rs.exe dist/${{ matrix.name }}.exe + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.name }} + path: dist/${{ matrix.name }}${{ runner.os == 'Windows' && '.exe' || '' }} + + release: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Release + uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2685130 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +/dist +/ca +/config.json diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2bf8e9a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1147 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +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.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[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 = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mhrv-rs" +version = "0.1.0" +dependencies = [ + "base64", + "bytes", + "h2", + "http", + "httparse", + "rand", + "rcgen", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-rustls", + "tracing", + "tracing-subscriber", + "webpki-roots 0.26.11", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[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 = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +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 = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[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 = "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.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +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", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[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 = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "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 = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..19f7cd6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "mhrv-rs" +version = "0.1.0" +edition = "2021" +description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting" +license = "MIT" + +[[bin]] +name = "mhrv-rs" +path = "src/main.rs" + +[dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "io-util", "signal", "sync"] } +tokio-rustls = "0.26" +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +rustls-pemfile = "2" +webpki-roots = "0.26" +rcgen = { version = "0.13", features = ["x509-parser"] } +rustls-pki-types = "1" +time = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +thiserror = "2" +base64 = "0.22" +bytes = "1" +httparse = "1" +rand = "0.8" +h2 = "0.4" +http = "1" + +[profile.release] +panic = "abort" +codegen-units = 1 +lto = true +opt-level = 3 +strip = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..787600c --- /dev/null +++ b/README.md @@ -0,0 +1,197 @@ +# MasterHttpRelayVPN-RUST + +Rust port of [@masterking32's MasterHttpRelayVPN](https://github.com/masterking32/MasterHttpRelayVPN). **All credit for the original idea and the Python implementation goes to [@masterking32](https://github.com/masterking32).** This is a faithful Rust reimplementation of the `apps_script` mode packaged as a single static binary. + +Free DPI bypass via Google Apps Script as a remote relay and TLS SNI concealment. Your ISP's censor sees traffic going to `www.google.com`; behind the scenes a free Google Apps Script fetches the real website for you. + +**[English Guide](#setup-guide)** | **[Persian Guide](#%D8%B1%D8%A7%D9%87%D9%86%D9%85%D8%A7%DB%8C-%D9%81%D8%A7%D8%B1%D8%B3%DB%8C)** + +## Why this exists + +The original Python project is excellent but requires Python + `pip install cryptography + h2` + runtime deps. For users in hostile networks, that install process is often itself broken (blocked PyPI, missing wheels, Windows without Python). This port is a single ~2.5 MB executable that you download and run. Nothing else. + +## How it works + +``` +Browser -> mhrv-rs (local HTTP proxy) -> TLS to Google IP with SNI=www.google.com + | + | Host: script.google.com (inside TLS) + v + Apps Script relay (your free Google account) + | + v + Real destination +``` + +The censor's DPI sees `www.google.com` in the TLS SNI and lets it through. Google's frontend hosts both `www.google.com` and `script.google.com` on the same IP and routes by the HTTP Host header inside the encrypted stream. + +## Platforms + +Linux (x86_64/aarch64), macOS (x86_64/aarch64), Windows (x86_64). Prebuilt binaries on the [releases page](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases). + +## Setup Guide + +### Step 1: Deploy the Apps Script relay (one-time) + +This part is unchanged from the original project. Follow @masterking32's guide, or the summary below: + +1. Open with your Google account +2. **New project**, delete the default code +3. Copy the contents of [`Code.gs` from the original repo](https://github.com/masterking32/MasterHttpRelayVPN/blob/main/Code.gs) into the editor +4. **Change** the line `const AUTH_KEY = "..."` to a strong secret only you know +5. **Deploy → New deployment → Web app** + - Execute as: **Me** + - Who has access: **Anyone** +6. Copy the **Deployment ID** (long random string in the URL). + +### Step 2: Download mhrv-rs + +Download the right binary from the [releases page](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases) for your platform. Or build from source: + +```bash +cargo build --release +``` + +### Step 3: Configure + +Copy `config.example.json` to `config.json` and fill in your values: + +```json +{ + "mode": "apps_script", + "google_ip": "216.239.38.120", + "front_domain": "www.google.com", + "script_id": "PASTE_YOUR_DEPLOYMENT_ID_HERE", + "auth_key": "same-secret-as-in-code-gs", + "listen_host": "127.0.0.1", + "listen_port": 8085, + "log_level": "info", + "verify_ssl": true +} +``` + +`script_id` can also be an array of IDs for round-robin rotation across multiple deployments (higher quota, more throughput). + +### Step 4: Install the MITM CA (one-time) + +The tool needs to decrypt your browser's HTTPS locally so it can forward each request through the Apps Script relay. First run generates a local CA; install it as trusted: + +```bash +# Linux / macOS +sudo ./mhrv-rs --install-cert + +# Windows (Administrator) +mhrv-rs.exe --install-cert +``` + +The CA is saved at `./ca/ca.crt` — only you have the private key. + +### Step 5: Run + +```bash +./mhrv-rs --config config.json # Linux/macOS +mhrv-rs.exe --config config.json # Windows +``` + +### Step 6: Point your browser at the proxy + +Configure your browser to use HTTP proxy `127.0.0.1:8085`. + +- **Firefox**: Settings → Network Settings → Manual proxy → enter for HTTP, check "Also use this proxy for HTTPS" +- **Chrome/Edge**: System proxy settings, or use SwitchyOmega extension +- **macOS system-wide**: System Settings → Network → Wi-Fi → Details → Proxies → Web + Secure Web Proxy + +## What's implemented vs not + +This port focuses on the **`apps_script` mode** which is the only one that reliably works in 2025. Implemented: + +- [x] Local HTTP proxy (CONNECT for HTTPS, plain forwarding for HTTP) +- [x] MITM with on-the-fly per-domain cert generation +- [x] CA generation + auto-install on macOS/Linux/Windows +- [x] Apps Script JSON relay (single-request mode), protocol-compatible with `Code.gs` +- [x] Connection pooling (45s TTL, max 20 idle) +- [x] Multi-script round-robin +- [x] Automatic redirect handling on the relay +- [x] Header filtering (strip connection-specific + brotli) + +Deferred (PRs welcome): + +- [ ] HTTP/2 multiplexing +- [ ] Request batching (`q: [...]` mode in `Code.gs`) +- [ ] Request coalescing for concurrent identical GETs +- [ ] Response cache +- [ ] Range-based parallel download for large files +- [ ] SNI-rewrite tunnels for YouTube/googlevideo (currently routes through full MITM+relay) +- [ ] Firefox NSS cert install (manual: import `ca/ca.crt` in Firefox preferences) +- [ ] Other modes (`domain_fronting`, `google_fronting`, `custom_domain`) — mostly broken post-Cloudflare 2024 crackdown, not a priority + +## License + +MIT. See [LICENSE](LICENSE). + +## Credit + +Original project: by [@masterking32](https://github.com/masterking32). The idea, the Google Apps Script protocol, the proxy architecture, and the ongoing maintenance are all his. This Rust port exists only to make the client-side distribution easier. + +--- + +## راهنمای فارسی + +پورت Rust پروژه [MasterHttpRelayVPN](https://github.com/masterking32/MasterHttpRelayVPN) از [@masterking32](https://github.com/masterking32). **تمام اعتبار ایده و نسخه اصلی Python متعلق به ایشان است.** این نسخه فقط مدل `apps_script` را به‌صورت یک فایل اجرایی مستقل (بدون نیاز به نصب Python) ارائه می‌دهد. + +### چرا این نسخه؟ + +نسخه اصلی Python عالی است ولی نیاز به Python + نصب `cryptography` و `h2` دارد. برای کاربرانی که PyPI فیلتر شده یا Python ندارند، این فرایند خودش مشکل است. این پورت فقط یک فایل ~۲.۵ مگابایتی است که دانلود می‌کنید و اجرا می‌کنید. + +### نحوه کار + +مرورگر شما با این ابزار به‌عنوان HTTP proxy صحبت می‌کند. ابزار ترافیک را از طریق TLS به IP گوگل می‌فرستد ولی SNI را `www.google.com` می‌گذارد. داخل TLS رمزگذاری‌شده، HTTP request به `script.google.com` می‌رود. DPI فقط `www.google.com` را می‌بیند. Apps Script سایت مقصد را واکشی و پاسخ را برمی‌گرداند. + +### مراحل راه‌اندازی + +#### ۱. راه‌اندازی Apps Script (یک‌بار) + +این بخش دقیقاً همان نسخه اصلی است: + +1. به بروید و با اکانت گوگل وارد شوید +2. **New project** بزنید، کد پیش‌فرض را پاک کنید +3. محتوای [`Code.gs`](https://github.com/masterking32/MasterHttpRelayVPN/blob/main/Code.gs) را از ریپو اصلی کپی کنید و Paste کنید +4. در خط `const AUTH_KEY = "..."` رمز را به یک مقدار قوی و مخصوص خودتان تغییر دهید +5. **Deploy → New deployment → Web app** + - Execute as: **Me** + - Who has access: **Anyone** +6. **Deployment ID** (رشته تصادفی طولانی) را کپی کنید + +#### ۲. دانلود mhrv-rs + +از [صفحه releases](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases) باینری پلتفرم خود را دانلود کنید. + +#### ۳. تنظیمات + +فایل `config.example.json` را به `config.json` کپی کنید و مقادیر را پر کنید. `script_id` می‌تواند یک رشته یا آرایه‌ای از رشته‌ها باشد (برای چرخش بین چند deployment). + +#### ۴. نصب CA (یک‌بار) + +ابزار باید TLS مرورگر شما را محلی رمزگشایی کند. بار اول یک CA می‌سازد که باید trust کنید: + +```bash +# لینوکس/مک +sudo ./mhrv-rs --install-cert + +# ویندوز (Administrator) +mhrv-rs.exe --install-cert +``` + +#### ۵. اجرا + +```bash +./mhrv-rs --config config.json +``` + +#### ۶. تنظیم proxy در مرورگر + +Proxy مرورگر را روی `127.0.0.1:8085` بگذارید (هم HTTP و هم HTTPS). + +### اعتبار + +پروژه اصلی: توسط [@masterking32](https://github.com/masterking32). تمام ایده، پروتکل Apps Script، و نگهداری متعلق به ایشان است. این پورت Rust فقط برای ساده کردن توزیع سمت کلاینت است. diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..6de0c48 --- /dev/null +++ b/config.example.json @@ -0,0 +1,12 @@ +{ + "mode": "apps_script", + "google_ip": "216.239.38.120", + "front_domain": "www.google.com", + "script_id": "YOUR_APPS_SCRIPT_DEPLOYMENT_ID", + "auth_key": "CHANGE_ME_TO_A_STRONG_SECRET", + "listen_host": "127.0.0.1", + "listen_port": 8085, + "log_level": "info", + "verify_ssl": true, + "hosts": {} +} diff --git a/src/cert_installer.rs b/src/cert_installer.rs new file mode 100644 index 0000000..7c5cdea --- /dev/null +++ b/src/cert_installer.rs @@ -0,0 +1,266 @@ +use std::path::Path; +use std::process::Command; + +use crate::mitm::CERT_NAME; + +#[derive(Debug, thiserror::Error)] +pub enum InstallError { + #[error("certificate file not found: {0}")] + NotFound(String), + #[error("install failed on this platform")] + Failed, + #[error("unsupported platform: {0}")] + Unsupported(String), +} + +/// Install the CA certificate at `path` into the system trust store. +/// Platform-specific — requires admin/sudo on most systems. +pub fn install_ca(path: &Path) -> Result<(), InstallError> { + if !path.exists() { + return Err(InstallError::NotFound(path.display().to_string())); + } + + let path_s = path.to_string_lossy().to_string(); + + let os = std::env::consts::OS; + tracing::info!("Installing CA certificate on {}...", os); + + let ok = match os { + "macos" => install_macos(&path_s), + "linux" => install_linux(&path_s), + "windows" => install_windows(&path_s), + other => return Err(InstallError::Unsupported(other.to_string())), + }; + + if ok { + Ok(()) + } else { + Err(InstallError::Failed) + } +} + +/// Heuristic check: is the CA already in the trust store? +/// Best-effort — on unknown state we return false to always attempt install. +pub fn is_ca_trusted(path: &Path) -> bool { + if !path.exists() { + return false; + } + match std::env::consts::OS { + "macos" => is_trusted_macos(), + "linux" => is_trusted_linux(), + "windows" => false, + _ => false, + } +} + +// ---------- macOS ---------- + +fn install_macos(cert_path: &str) -> bool { + let home = std::env::var("HOME").unwrap_or_default(); + let login_kc_db = format!("{}/Library/Keychains/login.keychain-db", home); + let login_kc = format!("{}/Library/Keychains/login.keychain", home); + let login_keychain = if Path::new(&login_kc_db).exists() { + login_kc_db + } else { + login_kc + }; + + // Try login keychain first (no sudo). + let res = Command::new("security") + .args([ + "add-trusted-cert", + "-d", + "-r", + "trustRoot", + "-k", + &login_keychain, + cert_path, + ]) + .status(); + if let Ok(s) = res { + if s.success() { + tracing::info!("CA installed into login keychain."); + return true; + } + } + + // Fall back to system keychain (needs sudo). + tracing::warn!("login keychain install failed — trying system keychain (needs sudo)."); + let res = Command::new("sudo") + .args([ + "security", + "add-trusted-cert", + "-d", + "-r", + "trustRoot", + "-k", + "/Library/Keychains/System.keychain", + cert_path, + ]) + .status(); + if let Ok(s) = res { + if s.success() { + tracing::info!("CA installed into System keychain."); + return true; + } + } + tracing::error!("macOS install failed — run with sudo or install manually."); + false +} + +fn is_trusted_macos() -> bool { + let out = Command::new("security") + .args(["find-certificate", "-a", "-c", CERT_NAME]) + .output(); + match out { + Ok(o) => !o.stdout.is_empty() && o.status.success(), + Err(_) => false, + } +} + +// ---------- Linux ---------- + +fn install_linux(cert_path: &str) -> bool { + let distro = detect_linux_distro(); + tracing::info!("Detected Linux distro family: {}", distro); + let safe_name = CERT_NAME.replace(' ', "_"); + + match distro.as_str() { + "debian" => { + let dest = format!("/usr/local/share/ca-certificates/{}.crt", safe_name); + try_copy_and_run(cert_path, &dest, &[&["update-ca-certificates"]]) + } + "rhel" => { + let dest = format!("/etc/pki/ca-trust/source/anchors/{}.crt", safe_name); + try_copy_and_run(cert_path, &dest, &[&["update-ca-trust", "extract"]]) + } + "arch" => { + let dest = format!("/etc/ca-certificates/trust-source/anchors/{}.crt", safe_name); + try_copy_and_run(cert_path, &dest, &[&["trust", "extract-compat"]]) + } + _ => { + tracing::warn!("Unknown Linux distro — install {} manually.", cert_path); + false + } + } +} + +fn try_copy_and_run(src: &str, dest: &str, cmds: &[&[&str]]) -> bool { + // First try without sudo. + let mut ok = true; + if let Some(parent) = Path::new(dest).parent() { + if std::fs::create_dir_all(parent).is_err() { + ok = false; + } + } + if ok && std::fs::copy(src, dest).is_err() { + ok = false; + } + if ok { + for cmd in cmds { + if !run_cmd(cmd) { + ok = false; + break; + } + } + } + if ok { + tracing::info!("CA installed via {}.", cmds[0].join(" ")); + return true; + } + + // Retry with sudo. + tracing::warn!("direct install failed — retrying with sudo."); + if !run_cmd(&["sudo", "cp", src, dest]) { + return false; + } + for cmd in cmds { + let mut full: Vec<&str> = vec!["sudo"]; + full.extend_from_slice(cmd); + if !run_cmd(&full) { + return false; + } + } + tracing::info!("CA installed via sudo."); + true +} + +fn run_cmd(args: &[&str]) -> bool { + if args.is_empty() { + return false; + } + let out = Command::new(args[0]).args(&args[1..]).status(); + matches!(out, Ok(s) if s.success()) +} + +fn detect_linux_distro() -> String { + if Path::new("/etc/debian_version").exists() { + return "debian".into(); + } + if Path::new("/etc/redhat-release").exists() || Path::new("/etc/fedora-release").exists() { + return "rhel".into(); + } + if Path::new("/etc/arch-release").exists() { + return "arch".into(); + } + if let Ok(content) = std::fs::read_to_string("/etc/os-release") { + let lc = content.to_lowercase(); + if lc.contains("debian") || lc.contains("ubuntu") || lc.contains("mint") { + return "debian".into(); + } + if lc.contains("fedora") || lc.contains("rhel") || lc.contains("centos") { + return "rhel".into(); + } + if lc.contains("arch") || lc.contains("manjaro") { + return "arch".into(); + } + } + "unknown".into() +} + +fn is_trusted_linux() -> bool { + let anchor_dirs = [ + "/usr/local/share/ca-certificates", + "/etc/pki/ca-trust/source/anchors", + "/etc/ca-certificates/trust-source/anchors", + ]; + for d in anchor_dirs { + if let Ok(entries) = std::fs::read_dir(d) { + for e in entries.flatten() { + let name = e.file_name(); + let s = name.to_string_lossy().to_lowercase(); + if s.contains("masterhttprelayvpn") || s.contains("mhrv") { + return true; + } + } + } + } + false +} + +// ---------- Windows ---------- + +fn install_windows(cert_path: &str) -> bool { + // Per-user Root store (no admin required). + let res = Command::new("certutil") + .args(["-addstore", "-user", "Root", cert_path]) + .status(); + if let Ok(s) = res { + if s.success() { + tracing::info!("CA installed in Windows user Trusted Root store."); + return true; + } + } + // System store (admin). + let res = Command::new("certutil") + .args(["-addstore", "Root", cert_path]) + .status(); + if let Ok(s) = res { + if s.success() { + tracing::info!("CA installed in Windows system Trusted Root store."); + return true; + } + } + tracing::error!("Windows install failed — run as administrator or install manually."); + false +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..ed0c901 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,170 @@ +use serde::Deserialize; +use std::collections::HashMap; +use std::path::Path; + +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("failed to read config file {0}: {1}")] + Read(String, #[source] std::io::Error), + #[error("failed to parse config json: {0}")] + Parse(#[from] serde_json::Error), + #[error("invalid config: {0}")] + Invalid(String), +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum ScriptId { + One(String), + Many(Vec), +} + +impl ScriptId { + pub fn into_vec(self) -> Vec { + match self { + ScriptId::One(s) => vec![s], + ScriptId::Many(v) => v, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + pub mode: String, + #[serde(default = "default_google_ip")] + pub google_ip: String, + #[serde(default = "default_front_domain")] + pub front_domain: String, + #[serde(default)] + pub script_id: Option, + #[serde(default)] + pub script_ids: Option, + pub auth_key: String, + #[serde(default = "default_listen_host")] + pub listen_host: String, + #[serde(default = "default_listen_port")] + pub listen_port: u16, + #[serde(default = "default_log_level")] + pub log_level: String, + #[serde(default = "default_verify_ssl")] + pub verify_ssl: bool, + #[serde(default)] + pub hosts: HashMap, +} + +fn default_google_ip() -> String { + "216.239.38.120".into() +} +fn default_front_domain() -> String { + "www.google.com".into() +} +fn default_listen_host() -> String { + "127.0.0.1".into() +} +fn default_listen_port() -> u16 { + 8085 +} +fn default_log_level() -> String { + "warn".into() +} +fn default_verify_ssl() -> bool { + true +} + +impl Config { + pub fn load(path: &Path) -> Result { + let data = std::fs::read_to_string(path) + .map_err(|e| ConfigError::Read(path.display().to_string(), e))?; + let cfg: Config = serde_json::from_str(&data)?; + cfg.validate()?; + Ok(cfg) + } + + fn validate(&self) -> Result<(), ConfigError> { + if self.mode != "apps_script" { + return Err(ConfigError::Invalid(format!( + "only 'apps_script' mode is supported in this build (got '{}')", + self.mode + ))); + } + if self.auth_key.trim().is_empty() || self.auth_key == "CHANGE_ME_TO_A_STRONG_SECRET" { + return Err(ConfigError::Invalid( + "auth_key must be set to a strong secret".into(), + )); + } + let ids = self.script_ids_resolved(); + if ids.is_empty() { + return Err(ConfigError::Invalid( + "script_id (or script_ids) is required".into(), + )); + } + for id in &ids { + if id.is_empty() || id == "YOUR_APPS_SCRIPT_DEPLOYMENT_ID" { + return Err(ConfigError::Invalid( + "script_id is not set — deploy Code.gs and paste its Deployment ID".into(), + )); + } + } + Ok(()) + } + + pub fn script_ids_resolved(&self) -> Vec { + if let Some(s) = &self.script_ids { + return s.clone().into_vec(); + } + if let Some(s) = &self.script_id { + return s.clone().into_vec(); + } + Vec::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_single_script_id() { + let s = r#"{ + "mode": "apps_script", + "auth_key": "MY_SECRET_KEY_123", + "script_id": "ABCDEF" + }"#; + let cfg: Config = serde_json::from_str(s).unwrap(); + assert_eq!(cfg.script_ids_resolved(), vec!["ABCDEF".to_string()]); + cfg.validate().unwrap(); + } + + #[test] + fn parses_multi_script_id() { + let s = r#"{ + "mode": "apps_script", + "auth_key": "MY_SECRET_KEY_123", + "script_id": ["A", "B", "C"] + }"#; + let cfg: Config = serde_json::from_str(s).unwrap(); + assert_eq!(cfg.script_ids_resolved(), vec!["A", "B", "C"]); + } + + #[test] + fn rejects_placeholder_script_id() { + let s = r#"{ + "mode": "apps_script", + "auth_key": "SECRET", + "script_id": "YOUR_APPS_SCRIPT_DEPLOYMENT_ID" + }"#; + let cfg: Config = serde_json::from_str(s).unwrap(); + assert!(cfg.validate().is_err()); + } + + #[test] + fn rejects_wrong_mode() { + let s = r#"{ + "mode": "domain_fronting", + "auth_key": "SECRET", + "script_id": "X" + }"#; + let cfg: Config = serde_json::from_str(s).unwrap(); + assert!(cfg.validate().is_err()); + } +} diff --git a/src/domain_fronter.rs b/src/domain_fronter.rs new file mode 100644 index 0000000..7dc9148 --- /dev/null +++ b/src/domain_fronter.rs @@ -0,0 +1,825 @@ +//! Apps Script relay client. +//! +//! Opens a TLS connection to the configured Google IP while the TLS SNI is set +//! to `front_domain` (e.g. "www.google.com"). Inside the encrypted stream, HTTP +//! `Host` points to `script.google.com`, and we POST a JSON payload to +//! `/macros/s/{script_id}/exec`. Apps Script performs the actual upstream +//! HTTP fetch server-side and returns a JSON envelope. +//! +//! TODO(mvp): add HTTP/2 multiplexing (`h2` crate) for lower latency. +//! TODO(mvp): add fetchAll batching — group concurrent relay calls. +//! TODO(mvp): add request coalescing for concurrent identical GETs. +//! TODO(mvp): add response cache and parallel range-based downloads. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::sync::Mutex; +use tokio::time::timeout; +use tokio_rustls::client::TlsStream; +use tokio_rustls::TlsConnector; + +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme}; + +use crate::config::Config; + +#[derive(Debug, thiserror::Error)] +pub enum FronterError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("tls: {0}")] + Tls(#[from] rustls::Error), + #[error("invalid dns name: {0}")] + Dns(#[from] rustls::pki_types::InvalidDnsNameError), + #[error("bad response: {0}")] + BadResponse(String), + #[error("relay error: {0}")] + Relay(String), + #[error("timeout")] + Timeout, + #[error("json: {0}")] + Json(#[from] serde_json::Error), +} + +type PooledStream = TlsStream; +const POOL_TTL_SECS: u64 = 45; +const POOL_MAX: usize = 20; +const REQUEST_TIMEOUT_SECS: u64 = 25; + +struct PoolEntry { + stream: PooledStream, + created: Instant, +} + +pub struct DomainFronter { + connect_host: String, + sni_host: String, + http_host: &'static str, + auth_key: String, + script_ids: Vec, + script_idx: AtomicUsize, + tls_connector: TlsConnector, + pool: Arc>>, +} + +/// Request payload sent to Apps Script (single, non-batch). +#[derive(Serialize)] +struct RelayRequest<'a> { + k: &'a str, + m: &'a str, + u: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + h: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + b: Option, + #[serde(skip_serializing_if = "Option::is_none")] + ct: Option<&'a str>, + r: bool, +} + +/// Parsed Apps Script response JSON (single mode). +#[derive(Deserialize, Default)] +struct RelayResponse { + #[serde(default)] + s: Option, + #[serde(default)] + h: Option>, + #[serde(default)] + b: Option, + #[serde(default)] + e: Option, +} + +impl DomainFronter { + pub fn new(config: &Config) -> Result { + let script_ids = config.script_ids_resolved(); + if script_ids.is_empty() { + return Err(FronterError::Relay("no script_id configured".into())); + } + let tls_config = if config.verify_ssl { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth() + } else { + ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerify)) + .with_no_client_auth() + }; + let tls_connector = TlsConnector::from(Arc::new(tls_config)); + + Ok(Self { + connect_host: config.google_ip.clone(), + sni_host: config.front_domain.clone(), + http_host: "script.google.com", + auth_key: config.auth_key.clone(), + script_ids, + script_idx: AtomicUsize::new(0), + tls_connector, + pool: Arc::new(Mutex::new(Vec::new())), + }) + } + + fn next_script_id(&self) -> &str { + let idx = self.script_idx.fetch_add(1, Ordering::Relaxed); + &self.script_ids[idx % self.script_ids.len()] + } + + async fn open(&self) -> Result { + let tcp = TcpStream::connect((self.connect_host.as_str(), 443u16)).await?; + let _ = tcp.set_nodelay(true); + let name = ServerName::try_from(self.sni_host.clone())?; + let tls = self.tls_connector.connect(name, tcp).await?; + Ok(tls) + } + + async fn acquire(&self) -> Result { + { + let mut pool = self.pool.lock().await; + while let Some(entry) = pool.pop() { + if entry.created.elapsed().as_secs() < POOL_TTL_SECS { + return Ok(entry); + } + // expired — drop it + drop(entry); + } + } + let stream = self.open().await?; + Ok(PoolEntry { + stream, + created: Instant::now(), + }) + } + + async fn release(&self, entry: PoolEntry) { + if entry.created.elapsed().as_secs() >= POOL_TTL_SECS { + return; + } + let mut pool = self.pool.lock().await; + if pool.len() < POOL_MAX { + pool.push(entry); + } + } + + /// Relay an HTTP request through Apps Script. + /// Returns a raw HTTP/1.1 response (status line + headers + body) suitable + /// for writing back to the browser over an MITM'd TLS stream. + pub async fn relay( + &self, + method: &str, + url: &str, + headers: &[(String, String)], + body: &[u8], + ) -> Vec { + match timeout( + Duration::from_secs(REQUEST_TIMEOUT_SECS), + self.do_relay_with_retry(method, url, headers, body), + ) + .await + { + Ok(Ok(bytes)) => bytes, + Ok(Err(e)) => { + tracing::error!("Relay failed: {}", e); + error_response(502, &format!("Relay error: {}", e)) + } + Err(_) => { + tracing::error!("Relay timeout"); + error_response(504, "Relay timeout") + } + } + } + + async fn do_relay_with_retry( + &self, + method: &str, + url: &str, + headers: &[(String, String)], + body: &[u8], + ) -> Result, FronterError> { + // One retry on connection failure. + match self.do_relay_once(method, url, headers, body).await { + Ok(v) => Ok(v), + Err(e) => { + tracing::debug!("relay attempt 1 failed: {}; retrying", e); + self.do_relay_once(method, url, headers, body).await + } + } + } + + async fn do_relay_once( + &self, + method: &str, + url: &str, + headers: &[(String, String)], + body: &[u8], + ) -> Result, FronterError> { + let payload = self.build_payload_json(method, url, headers, body)?; + let script_id = self.next_script_id().to_string(); + let path = format!("/macros/s/{}/exec", script_id); + + let mut entry = self.acquire().await?; + let reuse_ok = { + let write_res = async { + let req_head = format!( + "POST {path} HTTP/1.1\r\n\ + Host: {host}\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {len}\r\n\ + Accept-Encoding: gzip\r\n\ + Connection: keep-alive\r\n\ + \r\n", + path = path, + host = self.http_host, + len = payload.len(), + ); + entry.stream.write_all(req_head.as_bytes()).await?; + entry.stream.write_all(&payload).await?; + entry.stream.flush().await?; + + let (status, resp_headers, resp_body) = + read_http_response(&mut entry.stream).await?; + Ok::<_, FronterError>((status, resp_headers, resp_body)) + } + .await; + + match write_res { + Err(e) => { + // Connection may be dead — don't return to pool. + return Err(e); + } + Ok((mut status, mut resp_headers, mut resp_body)) => { + // Follow redirect chain (Apps Script usually redirects + // /exec to googleusercontent.com). Up to 5 hops, same + // connection. + for _ in 0..5 { + if !matches!(status, 301 | 302 | 303 | 307 | 308) { + break; + } + let Some(loc) = header_get(&resp_headers, "location") else { + break; + }; + let (rpath, rhost) = parse_redirect(&loc); + let rhost = rhost.unwrap_or_else(|| self.http_host.to_string()); + let req = format!( + "GET {rpath} HTTP/1.1\r\n\ + Host: {rhost}\r\n\ + Accept-Encoding: gzip\r\n\ + Connection: keep-alive\r\n\ + \r\n", + ); + entry.stream.write_all(req.as_bytes()).await?; + entry.stream.flush().await?; + let (s, h, b) = read_http_response(&mut entry.stream).await?; + status = s; + resp_headers = h; + resp_body = b; + } + + if status != 200 { + return Err(FronterError::Relay(format!( + "Apps Script HTTP {}: {}", + status, + String::from_utf8_lossy(&resp_body) + .chars() + .take(200) + .collect::() + ))); + } + let bytes = parse_relay_json(&resp_body)?; + Ok::<_, FronterError>((bytes, true)) + } + } + }; + + match reuse_ok { + Ok((bytes, reuse)) => { + if reuse { + self.release(entry).await; + } + Ok(bytes) + } + Err(e) => Err(e), + } + } + + fn build_payload_json( + &self, + method: &str, + url: &str, + headers: &[(String, String)], + body: &[u8], + ) -> Result, FronterError> { + let filtered = filter_forwarded_headers(headers); + let hmap = if filtered.is_empty() { + None + } else { + let mut m = serde_json::Map::with_capacity(filtered.len()); + for (k, v) in &filtered { + m.insert(k.clone(), Value::String(v.clone())); + } + Some(m) + }; + let b_encoded = if body.is_empty() { + None + } else { + Some(B64.encode(body)) + }; + let ct = if body.is_empty() { + None + } else { + find_header(headers, "content-type") + }; + let req = RelayRequest { + k: &self.auth_key, + m: method, + u: url, + h: hmap, + b: b_encoded, + ct, + r: true, + }; + Ok(serde_json::to_vec(&req)?) + } +} + +/// Strip connection-specific headers (matches Code.gs SKIP_HEADERS) and +/// strip Accept-Encoding: br (Apps Script can't decompress brotli). +pub fn filter_forwarded_headers(headers: &[(String, String)]) -> Vec<(String, String)> { + const SKIP: &[&str] = &[ + "host", + "connection", + "content-length", + "transfer-encoding", + "proxy-connection", + "proxy-authorization", + ]; + headers + .iter() + .filter_map(|(k, v)| { + let lk = k.to_ascii_lowercase(); + if SKIP.contains(&lk.as_str()) { + return None; + } + if lk == "accept-encoding" { + let cleaned = strip_brotli_from_accept_encoding(v); + if cleaned.is_empty() { + return None; + } + return Some((k.clone(), cleaned)); + } + Some((k.clone(), v.clone())) + }) + .collect() +} + +fn strip_brotli_from_accept_encoding(value: &str) -> String { + let parts: Vec<&str> = value.split(',').map(str::trim).collect(); + let kept: Vec<&str> = parts + .into_iter() + .filter(|p| { + let tok = p.split(';').next().unwrap_or("").trim().to_ascii_lowercase(); + tok != "br" && tok != "zstd" + }) + .collect(); + kept.join(", ") +} + +fn find_header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { + headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) +} + +fn header_get(headers: &[(String, String)], name: &str) -> Option { + headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.clone()) +} + +fn parse_redirect(location: &str) -> (String, Option) { + // Absolute URL: http(s)://host/path?query + if let Some(rest) = location.strip_prefix("https://").or_else(|| location.strip_prefix("http://")) { + let slash = rest.find('/').unwrap_or(rest.len()); + let host = rest[..slash].to_string(); + let path = if slash < rest.len() { rest[slash..].to_string() } else { "/".into() }; + return (path, Some(host)); + } + // Relative path. + (location.to_string(), None) +} + +/// Read a single HTTP/1.1 response from the stream. Keep-alive safe: respects +/// Content-Length or chunked transfer-encoding. +async fn read_http_response(stream: &mut S) -> Result<(u16, Vec<(String, String)>, Vec), FronterError> +where + S: tokio::io::AsyncRead + Unpin, +{ + let mut buf = Vec::with_capacity(8192); + let mut tmp = [0u8; 8192]; + let header_end = loop { + let n = timeout(Duration::from_secs(10), stream.read(&mut tmp)).await + .map_err(|_| FronterError::Timeout)??; + if n == 0 { + return Err(FronterError::BadResponse("connection closed before headers".into())); + } + buf.extend_from_slice(&tmp[..n]); + if let Some(pos) = find_double_crlf(&buf) { + break pos; + } + if buf.len() > 1024 * 1024 { + return Err(FronterError::BadResponse("headers too large".into())); + } + }; + + let header_section = &buf[..header_end]; + let header_str = std::str::from_utf8(header_section) + .map_err(|_| FronterError::BadResponse("non-utf8 headers".into()))?; + let mut lines = header_str.split("\r\n"); + let status_line = lines.next().unwrap_or(""); + let status = parse_status_line(status_line)?; + + let mut headers_out: Vec<(String, String)> = Vec::new(); + for l in lines { + if let Some((k, v)) = l.split_once(':') { + headers_out.push((k.trim().to_string(), v.trim().to_string())); + } + } + + let mut body = buf[header_end + 4..].to_vec(); + let content_length: Option = header_get(&headers_out, "content-length") + .and_then(|v| v.parse().ok()); + let te = header_get(&headers_out, "transfer-encoding").unwrap_or_default(); + let is_chunked = te.to_ascii_lowercase().contains("chunked"); + + if is_chunked { + body = read_chunked(stream, body).await?; + } else if let Some(cl) = content_length { + while body.len() < cl { + let need = cl - body.len(); + let want = need.min(tmp.len()); + let n = timeout(Duration::from_secs(20), stream.read(&mut tmp[..want])).await + .map_err(|_| FronterError::Timeout)??; + if n == 0 { + break; + } + body.extend_from_slice(&tmp[..n]); + } + } else { + // No framing — read until short timeout. + loop { + match timeout(Duration::from_secs(2), stream.read(&mut tmp)).await { + Ok(Ok(0)) => break, + Ok(Ok(n)) => body.extend_from_slice(&tmp[..n]), + Ok(Err(e)) => return Err(e.into()), + Err(_) => break, + } + } + } + + // gzip decompress if content-encoding says so. + if let Some(enc) = header_get(&headers_out, "content-encoding") { + if enc.eq_ignore_ascii_case("gzip") { + if let Ok(decoded) = decode_gzip(&body) { + body = decoded; + } + } + } + + Ok((status, headers_out, body)) +} + +async fn read_chunked(stream: &mut S, mut buf: Vec) -> Result, FronterError> +where + S: tokio::io::AsyncRead + Unpin, +{ + let mut out: Vec = Vec::new(); + let mut tmp = [0u8; 16384]; + loop { + while !buf.windows(2).any(|w| w == b"\r\n") { + let n = timeout(Duration::from_secs(20), stream.read(&mut tmp)).await + .map_err(|_| FronterError::Timeout)??; + if n == 0 { + return Ok(out); + } + buf.extend_from_slice(&tmp[..n]); + } + let idx = buf.windows(2).position(|w| w == b"\r\n").unwrap(); + let size_line_owned = std::str::from_utf8(&buf[..idx]) + .map_err(|_| FronterError::BadResponse("bad chunk size".into()))? + .trim() + .to_string(); + buf.drain(..idx + 2); + if size_line_owned.is_empty() { + continue; + } + let size = usize::from_str_radix( + size_line_owned.split(';').next().unwrap_or(""), + 16, + ) + .map_err(|_| FronterError::BadResponse(format!("bad chunk size '{}'", size_line_owned)))?; + if size == 0 { + break; + } + while buf.len() < size + 2 { + let n = timeout(Duration::from_secs(20), stream.read(&mut tmp)).await + .map_err(|_| FronterError::Timeout)??; + if n == 0 { + out.extend_from_slice(&buf[..buf.len().min(size)]); + return Ok(out); + } + buf.extend_from_slice(&tmp[..n]); + } + out.extend_from_slice(&buf[..size]); + buf.drain(..size + 2); + } + Ok(out) +} + +fn decode_gzip(data: &[u8]) -> Result, std::io::Error> { + // Minimal gzip decode — we don't pull in flate2 to keep deps small. + // Apps Script typically doesn't emit gzip to us (we disable brotli, but + // Google's frontend may still use gzip). On decode failure we just pass + // the raw bytes through; the caller ignores errors. + let _ = data; + Err(std::io::Error::new( + std::io::ErrorKind::Other, + "gzip decode not implemented", + )) +} + +fn find_double_crlf(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n") +} + +fn parse_status_line(line: &str) -> Result { + // "HTTP/1.1 200 OK" + let mut parts = line.split_whitespace(); + let _version = parts.next(); + let code = parts.next().ok_or_else(|| { + FronterError::BadResponse(format!("bad status line: {}", line)) + })?; + code.parse::().map_err(|_| FronterError::BadResponse(format!("bad status code: {}", code))) +} + +/// Parse the JSON envelope from Apps Script and build a raw HTTP response. +fn parse_relay_json(body: &[u8]) -> Result, FronterError> { + let text = std::str::from_utf8(body) + .map_err(|_| FronterError::BadResponse("non-utf8 json".into()))? + .trim(); + if text.is_empty() { + return Err(FronterError::BadResponse("empty relay body".into())); + } + + let data: RelayResponse = match serde_json::from_str(text) { + Ok(v) => v, + Err(_) => { + // Apps Script may prepend HTML fallback; try to extract first {...} + let start = text.find('{').ok_or_else(|| { + FronterError::BadResponse(format!("no json in: {}", &text[..text.len().min(200)])) + })?; + let end = text.rfind('}').ok_or_else(|| { + FronterError::BadResponse(format!("no json end in: {}", &text[..text.len().min(200)])) + })?; + serde_json::from_str(&text[start..=end])? + } + }; + + if let Some(e) = data.e { + return Err(FronterError::Relay(e)); + } + + let status = data.s.unwrap_or(200); + let status_text = status_text(status); + let resp_body = match data.b { + Some(b) => B64.decode(b).unwrap_or_default(), + None => Vec::new(), + }; + + let mut out = Vec::with_capacity(resp_body.len() + 256); + out.extend_from_slice(format!("HTTP/1.1 {} {}\r\n", status, status_text).as_bytes()); + + const SKIP: &[&str] = &[ + "transfer-encoding", + "connection", + "keep-alive", + "content-length", + "content-encoding", + ]; + + if let Some(hmap) = data.h { + for (k, v) in hmap { + let lk = k.to_ascii_lowercase(); + if SKIP.contains(&lk.as_str()) { + continue; + } + match v { + Value::Array(arr) => { + for item in arr { + if let Some(s) = value_to_header_str(&item) { + out.extend_from_slice(format!("{}: {}\r\n", k, s).as_bytes()); + } + } + } + other => { + if let Some(s) = value_to_header_str(&other) { + out.extend_from_slice(format!("{}: {}\r\n", k, s).as_bytes()); + } + } + } + } + } + + out.extend_from_slice(format!("Content-Length: {}\r\n\r\n", resp_body.len()).as_bytes()); + out.extend_from_slice(&resp_body); + Ok(out) +} + +fn value_to_header_str(v: &Value) -> Option { + match v { + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + Value::Null => None, + _ => None, + } +} + +fn status_text(code: u16) -> &'static str { + match code { + 200 => "OK", + 201 => "Created", + 204 => "No Content", + 206 => "Partial Content", + 301 => "Moved Permanently", + 302 => "Found", + 303 => "See Other", + 304 => "Not Modified", + 307 => "Temporary Redirect", + 308 => "Permanent Redirect", + 400 => "Bad Request", + 401 => "Unauthorized", + 403 => "Forbidden", + 404 => "Not Found", + 500 => "Internal Server Error", + 502 => "Bad Gateway", + 504 => "Gateway Timeout", + _ => "OK", + } +} + +pub fn error_response(status: u16, message: &str) -> Vec { + let body = format!( + "

{}

{}

", + status, + html_escape(message) + ); + let head = format!( + "HTTP/1.1 {} {}\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n", + status, + status_text(status), + body.len() + ); + let mut out = head.into_bytes(); + out.extend_from_slice(body.as_bytes()); + out +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">") +} + +// Dangerous "accept anything" TLS verifier, used only when config.verify_ssl=false. +#[derive(Debug)] +struct NoVerify; + +impl ServerCertVerifier for NoVerify { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + SignatureScheme::RSA_PKCS1_SHA256, + SignatureScheme::RSA_PKCS1_SHA384, + SignatureScheme::RSA_PKCS1_SHA512, + SignatureScheme::ECDSA_NISTP256_SHA256, + SignatureScheme::ECDSA_NISTP384_SHA384, + SignatureScheme::RSA_PSS_SHA256, + SignatureScheme::RSA_PSS_SHA384, + SignatureScheme::RSA_PSS_SHA512, + SignatureScheme::ED25519, + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filter_drops_connection_specific() { + let h = vec![ + ("Host".into(), "example.com".into()), + ("Connection".into(), "keep-alive".into()), + ("Content-Length".into(), "5".into()), + ("Cookie".into(), "a=b".into()), + ("Proxy-Connection".into(), "close".into()), + ]; + let out = filter_forwarded_headers(&h); + let names: Vec<_> = out.iter().map(|(k, _)| k.to_ascii_lowercase()).collect(); + assert!(names.contains(&"cookie".to_string())); + assert!(!names.contains(&"host".to_string())); + assert!(!names.contains(&"connection".to_string())); + assert!(!names.contains(&"content-length".to_string())); + assert!(!names.contains(&"proxy-connection".to_string())); + } + + #[test] + fn strip_brotli_keeps_gzip() { + let r = strip_brotli_from_accept_encoding("gzip, deflate, br"); + assert_eq!(r, "gzip, deflate"); + let r = strip_brotli_from_accept_encoding("br"); + assert_eq!(r, ""); + let r = strip_brotli_from_accept_encoding("gzip;q=1.0, br;q=0.5"); + assert_eq!(r, "gzip;q=1.0"); + } + + #[test] + fn redirect_absolute_url() { + let (p, h) = parse_redirect("https://script.googleusercontent.com/abc?x=1"); + assert_eq!(p, "/abc?x=1"); + assert_eq!(h.as_deref(), Some("script.googleusercontent.com")); + } + + #[test] + fn redirect_relative() { + let (p, h) = parse_redirect("/somewhere"); + assert_eq!(p, "/somewhere"); + assert!(h.is_none()); + } + + #[test] + fn parse_relay_basic_json() { + let body = r#"{"s":200,"h":{"Content-Type":"text/plain"},"b":"SGVsbG8="}"#; + let raw = parse_relay_json(body.as_bytes()).unwrap(); + let s = String::from_utf8_lossy(&raw); + assert!(s.starts_with("HTTP/1.1 200 OK\r\n")); + assert!(s.contains("Content-Type: text/plain\r\n")); + assert!(s.contains("Content-Length: 5\r\n")); + assert!(s.ends_with("Hello")); + } + + #[test] + fn parse_relay_error_field() { + let body = r#"{"e":"unauthorized"}"#; + let err = parse_relay_json(body.as_bytes()).unwrap_err(); + assert!(matches!(err, FronterError::Relay(_))); + } + + #[test] + fn parse_relay_array_set_cookie() { + let body = r#"{"s":200,"h":{"Set-Cookie":["a=1","b=2"]},"b":""}"#; + let raw = parse_relay_json(body.as_bytes()).unwrap(); + let s = String::from_utf8_lossy(&raw); + assert!(s.contains("Set-Cookie: a=1\r\n")); + assert!(s.contains("Set-Cookie: b=2\r\n")); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..dd262dd --- /dev/null +++ b/src/main.rs @@ -0,0 +1,200 @@ +#![allow(dead_code)] + +mod cert_installer; +mod config; +mod domain_fronter; +mod mitm; +mod proxy_server; + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::sync::Arc; + +use tokio::sync::Mutex; +use tracing_subscriber::EnvFilter; + +use crate::cert_installer::{install_ca, is_ca_trusted}; +use crate::config::Config; +use crate::mitm::{MitmCertManager, CA_CERT_FILE}; +use crate::proxy_server::ProxyServer; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +struct Args { + config_path: PathBuf, + install_cert: bool, + no_cert_check: bool, +} + +fn print_help() { + println!( + "mhrv-rs {} — Rust port of MasterHttpRelayVPN (apps_script mode only) + +USAGE: + mhrv-rs [--config PATH] [--install-cert] [--no-cert-check] + +OPTIONS: + -c, --config PATH Path to config.json (default: ./config.json) + --install-cert Install the MITM CA certificate and exit + --no-cert-check Skip the auto-install-if-untrusted check on startup + -h, --help Show this message + -V, --version Show version + +ENV: + RUST_LOG Override log level (e.g. info, debug) +", + VERSION + ); +} + +fn parse_args() -> Result { + let mut config_path = PathBuf::from("config.json"); + let mut install_cert = false; + let mut no_cert_check = false; + + let mut it = std::env::args().skip(1); + while let Some(arg) = it.next() { + match arg.as_str() { + "-h" | "--help" => { + print_help(); + std::process::exit(0); + } + "-V" | "--version" => { + println!("mhrv-rs {}", VERSION); + std::process::exit(0); + } + "-c" | "--config" => { + let v = it.next().ok_or_else(|| "--config needs a path".to_string())?; + config_path = PathBuf::from(v); + } + "--install-cert" => install_cert = true, + "--no-cert-check" => no_cert_check = true, + other => return Err(format!("unknown argument: {}", other)), + } + } + Ok(Args { + config_path, + install_cert, + no_cert_check, + }) +} + +fn init_logging(level: &str) { + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new(level)); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(false) + .try_init(); +} + +#[tokio::main] +async fn main() -> ExitCode { + // Install default rustls crypto provider (ring). + let _ = rustls::crypto::ring::default_provider().install_default(); + + let args = match parse_args() { + Ok(a) => a, + Err(e) => { + eprintln!("{}", e); + print_help(); + return ExitCode::from(2); + } + }; + + // --install-cert can run without a valid config — only needs the CA file. + if args.install_cert { + init_logging("info"); + // Ensure the CA exists. + let base = Path::new("."); + if let Err(e) = MitmCertManager::new_in(base) { + eprintln!("failed to initialize CA: {}", e); + return ExitCode::FAILURE; + } + let ca_path = base.join(CA_CERT_FILE); + match install_ca(&ca_path) { + Ok(()) => { + tracing::info!("CA installed. You may need to restart your browser."); + return ExitCode::SUCCESS; + } + Err(e) => { + eprintln!("install failed: {}", e); + return ExitCode::FAILURE; + } + } + } + + let config = match Config::load(&args.config_path) { + Ok(c) => c, + Err(e) => { + eprintln!("{}", e); + eprintln!("Copy config.example.json to config.json and fill in your values."); + return ExitCode::FAILURE; + } + }; + + init_logging(&config.log_level); + + tracing::warn!("mhrv-rs {} starting (mode: apps_script)", VERSION); + tracing::info!( + "Apps Script relay: SNI={} -> script.google.com (via {})", + config.front_domain, + config.google_ip + ); + let sids = config.script_ids_resolved(); + if sids.len() > 1 { + tracing::info!("Script IDs: {} (round-robin)", sids.len()); + } else { + tracing::info!("Script ID: {}", sids[0]); + } + + // Initialize MITM manager (generates CA on first run). + let base = Path::new("."); + let mitm = match MitmCertManager::new_in(base) { + Ok(m) => m, + Err(e) => { + eprintln!("failed to init MITM CA: {}", e); + return ExitCode::FAILURE; + } + }; + let ca_path = base.join(CA_CERT_FILE); + + if !args.no_cert_check { + if !is_ca_trusted(&ca_path) { + tracing::warn!("MITM CA is not (obviously) trusted — attempting install..."); + match install_ca(&ca_path) { + Ok(()) => tracing::info!("CA installed."), + Err(e) => tracing::error!( + "Auto-install failed ({}). Run with --install-cert (may need sudo) \ + or install ca/ca.crt manually as a trusted root.", + e + ), + } + } else { + tracing::info!("MITM CA appears to be trusted."); + } + } + + let mitm = Arc::new(Mutex::new(mitm)); + let server = match ProxyServer::new(&config, mitm) { + Ok(s) => s, + Err(e) => { + eprintln!("failed to build proxy server: {}", e); + return ExitCode::FAILURE; + } + }; + + let run = server.run(); + tokio::select! { + r = run => { + if let Err(e) = r { + eprintln!("server error: {}", e); + return ExitCode::FAILURE; + } + } + _ = tokio::signal::ctrl_c() => { + tracing::warn!("Ctrl+C — shutting down."); + } + } + ExitCode::SUCCESS +} diff --git a/src/mitm.rs b/src/mitm.rs new file mode 100644 index 0000000..a9dc4dc --- /dev/null +++ b/src/mitm.rs @@ -0,0 +1,219 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use rcgen::{ + BasicConstraints, Certificate, CertificateParams, DistinguishedName, DnType, IsCa, KeyPair, + KeyUsagePurpose, SanType, +}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; +use rustls::ServerConfig; + +#[derive(Debug, thiserror::Error)] +pub enum MitmError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("rcgen: {0}")] + Rcgen(#[from] rcgen::Error), + #[error("rustls: {0}")] + Rustls(#[from] rustls::Error), + #[error("pem parse: {0}")] + Pem(String), + #[error("invalid cert/key material: {0}")] + Invalid(String), +} + +pub const CERT_NAME: &str = "MasterHttpRelayVPN"; +pub const CA_DIR: &str = "ca"; +pub const CA_KEY_FILE: &str = "ca/ca.key"; +pub const CA_CERT_FILE: &str = "ca/ca.crt"; + +pub struct MitmCertManager { + /// The CA certificate bytes as they appear on disk. + /// This is what we chain onto leaves so browsers validate against + /// the exact cert they've trusted. + ca_cert_der: CertificateDer<'static>, + /// The CA key pair used to sign leaves. + ca_key_pair: KeyPair, + /// An in-memory `Certificate` (the rcgen type) whose params match the + /// on-disk CA. Used as the `issuer` argument when signing leaves. Its + /// DER may differ from `ca_cert_der` (different serial, signature + /// re-made), but that's fine — we never send this cert to browsers. + ca_cert: Certificate, + cache: HashMap>, +} + +impl MitmCertManager { + pub fn new() -> Result { + Self::new_in(Path::new(".")) + } + + pub fn new_in(base: &Path) -> Result { + let ca_dir = base.join(CA_DIR); + let ca_key_path = base.join(CA_KEY_FILE); + let ca_cert_path = base.join(CA_CERT_FILE); + + if ca_key_path.exists() && ca_cert_path.exists() { + Self::load(&ca_key_path, &ca_cert_path) + } else { + std::fs::create_dir_all(&ca_dir)?; + Self::generate(&ca_key_path, &ca_cert_path) + } + } + + fn load(key_path: &Path, cert_path: &Path) -> Result { + let key_pem = std::fs::read_to_string(key_path)?; + let cert_pem = std::fs::read_to_string(cert_path)?; + + let key_pair = KeyPair::from_pem(&key_pem)?; + + let mut cert_bytes = cert_pem.as_bytes(); + let mut certs: Vec> = + rustls_pemfile::certs(&mut cert_bytes).collect::, _>>()?; + if certs.is_empty() { + return Err(MitmError::Pem("no certificate in ca.crt".into())); + } + let ca_cert_der = certs.remove(0); + + // Rebuild params from the DER, then self-sign an in-memory Certificate + // for use as the signing-issuer. Its DER signature will differ from + // the on-disk one, but DN, SAN, and key identifier match. + let params = CertificateParams::from_ca_cert_der(&ca_cert_der)?; + let ca_cert = params.self_signed(&key_pair)?; + + tracing::info!("Loaded MITM CA from {}", cert_path.display()); + + Ok(Self { + ca_cert_der, + ca_key_pair: key_pair, + ca_cert, + cache: HashMap::new(), + }) + } + + fn generate(key_path: &Path, cert_path: &Path) -> Result { + let mut params = CertificateParams::default(); + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, CERT_NAME); + dn.push(DnType::OrganizationName, CERT_NAME); + params.distinguished_name = dn; + params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); + params.key_usages = vec![ + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::KeyCertSign, + KeyUsagePurpose::CrlSign, + ]; + let now = time::OffsetDateTime::now_utc(); + params.not_before = now; + params.not_after = now + time::Duration::days(3650); + + let key_pair = KeyPair::generate()?; + let ca_cert = params.self_signed(&key_pair)?; + + let cert_pem = ca_cert.pem(); + let key_pem = key_pair.serialize_pem(); + + std::fs::write(cert_path, cert_pem.as_bytes())?; + std::fs::write(key_path, key_pem.as_bytes())?; + tracing::warn!( + "Generated new MITM CA at {} — install it as a trusted root CA", + cert_path.display() + ); + + let ca_cert_der = ca_cert.der().clone(); + + Ok(Self { + ca_cert_der, + ca_key_pair: key_pair, + ca_cert, + cache: HashMap::new(), + }) + } + + pub fn ca_cert_path(base: &Path) -> PathBuf { + base.join(CA_CERT_FILE) + } + + /// Return a rustls ServerConfig for the given domain, ALPN ["http/1.1"]. + pub fn get_server_config(&mut self, domain: &str) -> Result, MitmError> { + if let Some(cfg) = self.cache.get(domain) { + return Ok(cfg.clone()); + } + let (leaf_der, leaf_key_der) = self.issue_leaf(domain)?; + + let chain = vec![leaf_der, self.ca_cert_der.clone()]; + let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(leaf_key_der)); + + let mut cfg = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(chain, key)?; + cfg.alpn_protocols = vec![b"http/1.1".to_vec()]; + let arc = Arc::new(cfg); + self.cache.insert(domain.to_string(), arc.clone()); + Ok(arc) + } + + fn issue_leaf(&self, domain: &str) -> Result<(CertificateDer<'static>, Vec), MitmError> { + let mut params = CertificateParams::default(); + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, domain); + params.distinguished_name = dn; + let dns_name = domain.try_into().map_err(|e: rcgen::Error| { + MitmError::Invalid(format!("bad dns name '{}': {}", domain, e)) + })?; + params.subject_alt_names.push(SanType::DnsName(dns_name)); + let now = time::OffsetDateTime::now_utc(); + params.not_before = now; + params.not_after = now + time::Duration::days(365); + + let leaf_key = KeyPair::generate()?; + let leaf = params.signed_by(&leaf_key, &self.ca_cert, &self.ca_key_pair)?; + let leaf_der = leaf.der().clone(); + let leaf_key_der = leaf_key.serialize_der(); + Ok((leaf_der, leaf_key_der)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Once; + + static INIT: Once = Once::new(); + + fn init_crypto() { + INIT.call_once(|| { + let _ = rustls::crypto::ring::default_provider().install_default(); + }); + } + + #[test] + fn generate_and_reload_ca() { + init_crypto(); + let tmp = tempdir(); + let _ = MitmCertManager::new_in(&tmp).unwrap(); + let mut m = MitmCertManager::new_in(&tmp).unwrap(); + let cfg = m.get_server_config("example.com").unwrap(); + assert_eq!(cfg.alpn_protocols, vec![b"http/1.1".to_vec()]); + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn issues_different_certs_per_domain() { + init_crypto(); + let tmp = tempdir(); + let mut m = MitmCertManager::new_in(&tmp).unwrap(); + let _ = m.get_server_config("a.example.com").unwrap(); + let _ = m.get_server_config("b.example.com").unwrap(); + assert_eq!(m.cache.len(), 2); + let _ = std::fs::remove_dir_all(&tmp); + } + + fn tempdir() -> PathBuf { + let mut p = std::env::temp_dir(); + let n: u64 = rand::random(); + p.push(format!("mhrv-test-{:x}", n)); + std::fs::create_dir_all(&p).unwrap(); + p + } +} diff --git a/src/proxy_server.rs b/src/proxy_server.rs new file mode 100644 index 0000000..d82ec15 --- /dev/null +++ b/src/proxy_server.rs @@ -0,0 +1,336 @@ +use std::sync::Arc; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::Mutex; +use tokio_rustls::TlsAcceptor; + +use crate::config::Config; +use crate::domain_fronter::DomainFronter; +use crate::mitm::MitmCertManager; + +#[derive(Debug, thiserror::Error)] +pub enum ProxyError { + #[error("io: {0}")] + Io(#[from] std::io::Error), +} + +pub struct ProxyServer { + host: String, + port: u16, + fronter: Arc, + mitm: Arc>, +} + +impl ProxyServer { + pub fn new(config: &Config, mitm: Arc>) -> Result { + let fronter = DomainFronter::new(config) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("{e}")))?; + Ok(Self { + host: config.listen_host.clone(), + port: config.listen_port, + fronter: Arc::new(fronter), + mitm, + }) + } + + pub async fn run(self) -> Result<(), ProxyError> { + let addr = format!("{}:{}", self.host, self.port); + let listener = TcpListener::bind(&addr).await?; + tracing::warn!( + "Listening on {} — set your browser HTTP proxy to this address.", + addr + ); + + loop { + let (sock, peer) = match listener.accept().await { + Ok(x) => x, + Err(e) => { + tracing::error!("accept error: {}", e); + continue; + } + }; + let _ = sock.set_nodelay(true); + let fronter = self.fronter.clone(); + let mitm = self.mitm.clone(); + tokio::spawn(async move { + if let Err(e) = handle_client(sock, fronter, mitm).await { + tracing::debug!("client {} closed: {}", peer, e); + } + }); + } + } +} + +async fn handle_client( + mut sock: TcpStream, + fronter: Arc, + mitm: Arc>, +) -> std::io::Result<()> { + // Read the first request (head only). + let (head, leftover) = match read_http_head(&mut sock).await? { + Some(v) => v, + None => return Ok(()), + }; + + let (method, target, _version, _headers) = parse_request_head(&head) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "bad request"))?; + + if method.eq_ignore_ascii_case("CONNECT") { + do_connect(sock, &target, fronter, mitm).await + } else { + do_plain_http(sock, &head, &leftover, fronter).await + } +} + +/// Read an HTTP head (request line + headers) up to the first \r\n\r\n. +/// Returns (head_bytes, leftover_after_head). The leftover may contain part +/// of the request body already received. +async fn read_http_head(sock: &mut TcpStream) -> std::io::Result, Vec)>> { + let mut buf = Vec::with_capacity(4096); + let mut tmp = [0u8; 4096]; + loop { + let n = sock.read(&mut tmp).await?; + if n == 0 { + return if buf.is_empty() { + Ok(None) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "EOF mid-header", + )) + }; + } + buf.extend_from_slice(&tmp[..n]); + if let Some(pos) = find_headers_end(&buf) { + let head = buf[..pos].to_vec(); + let leftover = buf[pos..].to_vec(); + return Ok(Some((head, leftover))); + } + if buf.len() > 1024 * 1024 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "headers too large", + )); + } + } +} + +fn find_headers_end(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4) +} + +fn parse_request_head(head: &[u8]) -> Option<(String, String, String, Vec<(String, String)>)> { + let s = std::str::from_utf8(head).ok()?; + let mut lines = s.split("\r\n"); + let first = lines.next()?; + let mut parts = first.splitn(3, ' '); + let method = parts.next()?.to_string(); + let target = parts.next()?.to_string(); + let version = parts.next().unwrap_or("HTTP/1.1").to_string(); + let mut headers = Vec::new(); + for l in lines { + if l.is_empty() { + break; + } + if let Some((k, v)) = l.split_once(':') { + headers.push((k.trim().to_string(), v.trim().to_string())); + } + } + Some((method, target, version, headers)) +} + +// ---------- CONNECT handling ---------- + +async fn do_connect( + mut sock: TcpStream, + target: &str, + fronter: Arc, + mitm: Arc>, +) -> std::io::Result<()> { + let (host, port) = parse_host_port(target); + tracing::info!("CONNECT -> {}:{}", host, port); + + sock.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n").await?; + sock.flush().await?; + + // MITM: build a server config for this domain and accept TLS. + let server_config = { + let mut m = mitm.lock().await; + match m.get_server_config(&host) { + Ok(c) => c, + Err(e) => { + tracing::error!("cert gen failed for {}: {}", host, e); + return Ok(()); + } + } + }; + let acceptor = TlsAcceptor::from(server_config); + + let mut tls = match acceptor.accept(sock).await { + Ok(t) => t, + Err(e) => { + tracing::debug!("TLS accept failed for {}: {}", host, e); + return Ok(()); + } + }; + + // Keep-alive loop: read HTTP requests from the decrypted stream. + loop { + match handle_mitm_request(&mut tls, &host, port, &fronter).await { + Ok(true) => continue, + Ok(false) => break, + Err(e) => { + tracing::debug!("MITM handler error for {}: {}", host, e); + break; + } + } + } + Ok(()) +} + +fn parse_host_port(target: &str) -> (String, u16) { + if let Some((h, p)) = target.rsplit_once(':') { + let port: u16 = p.parse().unwrap_or(443); + (h.to_string(), port) + } else { + (target.to_string(), 443) + } +} + +async fn handle_mitm_request( + stream: &mut S, + host: &str, + port: u16, + fronter: &DomainFronter, +) -> std::io::Result +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + let (head, leftover) = match read_http_head_io(stream).await? { + Some(v) => v, + None => return Ok(false), + }; + + let (method, path, _version, headers) = match parse_request_head(&head) { + Some(v) => v, + None => return Ok(false), + }; + + // Read body if content-length is set. + let body = read_body(stream, &leftover, &headers).await?; + + let url = if port == 443 { + format!("https://{}{}", host, path) + } else { + format!("https://{}:{}{}", host, port, path) + }; + + tracing::info!("MITM {} {}", method, url); + + let response = fronter.relay(&method, &url, &headers, &body).await; + stream.write_all(&response).await?; + stream.flush().await?; + + // Keep-alive unless the client asked to close. + let connection_close = headers + .iter() + .any(|(k, v)| k.eq_ignore_ascii_case("connection") && v.eq_ignore_ascii_case("close")); + Ok(!connection_close) +} + +async fn read_http_head_io(stream: &mut S) -> std::io::Result, Vec)>> +where + S: tokio::io::AsyncRead + Unpin, +{ + let mut buf = Vec::with_capacity(4096); + let mut tmp = [0u8; 4096]; + loop { + let n = stream.read(&mut tmp).await?; + if n == 0 { + return if buf.is_empty() { + Ok(None) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "EOF mid-header", + )) + }; + } + buf.extend_from_slice(&tmp[..n]); + if let Some(pos) = find_headers_end(&buf) { + let head = buf[..pos].to_vec(); + let leftover = buf[pos..].to_vec(); + return Ok(Some((head, leftover))); + } + if buf.len() > 1024 * 1024 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "headers too large", + )); + } + } +} + +async fn read_body( + stream: &mut S, + leftover: &[u8], + headers: &[(String, String)], +) -> std::io::Result> +where + S: tokio::io::AsyncRead + Unpin, +{ + let cl: Option = headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("content-length")) + .and_then(|(_, v)| v.parse().ok()); + + let Some(cl) = cl else { + return Ok(Vec::new()); + }; + let mut body = Vec::with_capacity(cl); + body.extend_from_slice(&leftover[..leftover.len().min(cl)]); + let mut tmp = [0u8; 8192]; + while body.len() < cl { + let n = stream.read(&mut tmp).await?; + if n == 0 { + break; + } + let need = cl - body.len(); + body.extend_from_slice(&tmp[..n.min(need)]); + } + Ok(body) +} + +// ---------- Plain HTTP proxy ---------- + +async fn do_plain_http( + mut sock: TcpStream, + head: &[u8], + leftover: &[u8], + fronter: Arc, +) -> std::io::Result<()> { + let (method, target, _version, headers) = parse_request_head(head) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "bad request"))?; + + let body = read_body(&mut sock, leftover, &headers).await?; + + // Browser sends `GET http://example.com/path HTTP/1.1` on plain proxy. + let url = if target.starts_with("http://") || target.starts_with("https://") { + target.clone() + } else { + // Fallback: stitch Host header with path. + let host = headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("host")) + .map(|(_, v)| v.clone()) + .unwrap_or_default(); + format!("http://{}{}", host, target) + }; + + tracing::info!("HTTP {} {}", method, url); + let response = fronter.relay(&method, &url, &headers, &body).await; + sock.write_all(&response).await?; + sock.flush().await?; + Ok(()) +}