mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-17 21:24:48 +03:00
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.
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/target
|
||||||
|
/dist
|
||||||
|
/ca
|
||||||
|
/config.json
|
||||||
Generated
+1147
File diff suppressed because it is too large
Load Diff
+38
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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 <https://script.google.com> 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: <https://github.com/masterking32/MasterHttpRelayVPN> 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. به <https://script.google.com> بروید و با اکانت گوگل وارد شوید
|
||||||
|
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).
|
||||||
|
|
||||||
|
### اعتبار
|
||||||
|
|
||||||
|
پروژه اصلی: <https://github.com/masterking32/MasterHttpRelayVPN> توسط [@masterking32](https://github.com/masterking32). تمام ایده، پروتکل Apps Script، و نگهداری متعلق به ایشان است. این پورت Rust فقط برای ساده کردن توزیع سمت کلاینت است.
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+170
@@ -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<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScriptId {
|
||||||
|
pub fn into_vec(self) -> Vec<String> {
|
||||||
|
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<ScriptId>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub script_ids: Option<ScriptId>,
|
||||||
|
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<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Self, ConfigError> {
|
||||||
|
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<String> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TcpStream>;
|
||||||
|
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<String>,
|
||||||
|
script_idx: AtomicUsize,
|
||||||
|
tls_connector: TlsConnector,
|
||||||
|
pool: Arc<Mutex<Vec<PoolEntry>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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_json::Map<String, Value>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
b: Option<String>,
|
||||||
|
#[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<u16>,
|
||||||
|
#[serde(default)]
|
||||||
|
h: Option<serde_json::Map<String, Value>>,
|
||||||
|
#[serde(default)]
|
||||||
|
b: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
e: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DomainFronter {
|
||||||
|
pub fn new(config: &Config) -> Result<Self, FronterError> {
|
||||||
|
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<PooledStream, FronterError> {
|
||||||
|
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<PoolEntry, FronterError> {
|
||||||
|
{
|
||||||
|
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<u8> {
|
||||||
|
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<Vec<u8>, 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<Vec<u8>, 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::<String>()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
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<Vec<u8>, 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<String> {
|
||||||
|
headers
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| k.eq_ignore_ascii_case(name))
|
||||||
|
.map(|(_, v)| v.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_redirect(location: &str) -> (String, Option<String>) {
|
||||||
|
// 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<S>(stream: &mut S) -> Result<(u16, Vec<(String, String)>, Vec<u8>), 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<usize> = 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<S>(stream: &mut S, mut buf: Vec<u8>) -> Result<Vec<u8>, FronterError>
|
||||||
|
where
|
||||||
|
S: tokio::io::AsyncRead + Unpin,
|
||||||
|
{
|
||||||
|
let mut out: Vec<u8> = 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<Vec<u8>, 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<usize> {
|
||||||
|
buf.windows(4).position(|w| w == b"\r\n\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_status_line(line: &str) -> Result<u16, FronterError> {
|
||||||
|
// "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::<u16>().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<Vec<u8>, 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<String> {
|
||||||
|
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<u8> {
|
||||||
|
let body = format!(
|
||||||
|
"<html><body><h1>{}</h1><p>{}</p></body></html>",
|
||||||
|
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<ServerCertVerified, rustls::Error> {
|
||||||
|
Ok(ServerCertVerified::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_cert: &CertificateDer<'_>,
|
||||||
|
_dss: &DigitallySignedStruct,
|
||||||
|
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Ok(HandshakeSignatureValid::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_cert: &CertificateDer<'_>,
|
||||||
|
_dss: &DigitallySignedStruct,
|
||||||
|
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Ok(HandshakeSignatureValid::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+200
@@ -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<Args, String> {
|
||||||
|
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
|
||||||
|
}
|
||||||
+219
@@ -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<String, Arc<ServerConfig>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MitmCertManager {
|
||||||
|
pub fn new() -> Result<Self, MitmError> {
|
||||||
|
Self::new_in(Path::new("."))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_in(base: &Path) -> Result<Self, MitmError> {
|
||||||
|
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<Self, MitmError> {
|
||||||
|
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<CertificateDer<'static>> =
|
||||||
|
rustls_pemfile::certs(&mut cert_bytes).collect::<Result<Vec<_>, _>>()?;
|
||||||
|
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<Self, MitmError> {
|
||||||
|
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<Arc<ServerConfig>, 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<u8>), 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DomainFronter>,
|
||||||
|
mitm: Arc<Mutex<MitmCertManager>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxyServer {
|
||||||
|
pub fn new(config: &Config, mitm: Arc<Mutex<MitmCertManager>>) -> Result<Self, ProxyError> {
|
||||||
|
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<DomainFronter>,
|
||||||
|
mitm: Arc<Mutex<MitmCertManager>>,
|
||||||
|
) -> 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<Option<(Vec<u8>, Vec<u8>)>> {
|
||||||
|
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<usize> {
|
||||||
|
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<DomainFronter>,
|
||||||
|
mitm: Arc<Mutex<MitmCertManager>>,
|
||||||
|
) -> 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<S>(
|
||||||
|
stream: &mut S,
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
fronter: &DomainFronter,
|
||||||
|
) -> std::io::Result<bool>
|
||||||
|
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<S>(stream: &mut S) -> std::io::Result<Option<(Vec<u8>, Vec<u8>)>>
|
||||||
|
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<S>(
|
||||||
|
stream: &mut S,
|
||||||
|
leftover: &[u8],
|
||||||
|
headers: &[(String, String)],
|
||||||
|
) -> std::io::Result<Vec<u8>>
|
||||||
|
where
|
||||||
|
S: tokio::io::AsyncRead + Unpin,
|
||||||
|
{
|
||||||
|
let cl: Option<usize> = 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<DomainFronter>,
|
||||||
|
) -> 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(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user