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:
therealaleph
2026-04-21 18:03:03 +03:00
commit 2dd8be72ca
13 changed files with 3543 additions and 0 deletions
+108
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
/target
/dist
/ca
/config.json
Generated
+1147
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -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
+21
View File
@@ -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.
+197
View File
@@ -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 فقط برای ساده کردن توزیع سمت کلاینت است.
+12
View File
@@ -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": {}
}
+266
View File
@@ -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
View File
@@ -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());
}
}
+825
View File
@@ -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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
}
// 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
View File
@@ -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
View File
@@ -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
}
}
+336
View File
@@ -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(())
}