71 Commits

Author SHA1 Message Date
lapp 44b2481040 Update TODO.md 2026-05-10 13:11:14 -07:00
lapp 923c1bc49b minor fixes 2026-05-10 13:08:55 -07:00
denuitt1 46f60d5141 Merge pull request #165 from AryaAhmadii/main
add VM and LAN sharing guides
2026-05-10 13:04:56 -07:00
lapp e7582422c0 feat(forwarder): scope upstream forwarder via forwarder_hosts config #160 2026-05-10 12:54:02 -07:00
Arya Ahmadi d3378ccc8e Merge branch 'denuitt1:main' into main 2026-05-10 23:07:29 +03:30
Arya Ahmadi f21aebff09 docs: add VM proxy setup and LAN sharing guides (fa) 2026-05-10 23:06:20 +03:30
denuitt1 75aeb90964 Merge pull request #157 from onlymaj/fix/multi-script-broken-id-blacklist
fix(relay): rotate script IDs when one returns a bad response
2026-05-10 12:35:24 -07:00
denuitt1 d43c71b4ce Merge pull request #164 from denuitt1/revert-160-fix/forwarder-hosts-allowlist
Revert "feat(forwarder): scope upstream forwarder via forwarder_hosts config"
2026-05-10 12:34:52 -07:00
denuitt1 c935b87293 Revert "feat(forwarder): scope upstream forwarder via forwarder_hosts config" 2026-05-10 12:33:23 -07:00
denuitt1 b13f778cb6 Added Contributors section
Updated README to include additional sections and formatting.
2026-05-10 12:28:57 -07:00
denuitt1 a22cc31a3f Merge pull request #160 from onlymaj/fix/forwarder-hosts-allowlist
feat(forwarder): scope upstream forwarder via forwarder_hosts config
2026-05-10 12:23:40 -07:00
denuitt1 cba912bab4 Merge pull request #161 from NTcompanyYT/patch-1
New Video link added
2026-05-10 12:21:38 -07:00
Arya Ahmadi 29cb4b5f55 docs: add VM proxy setup and LAN sharing guides 2026-05-10 22:46:58 +03:30
Nima Taheri | (NT) 0a58baa6ad New Video link added 2026-05-10 21:29:54 +03:30
Mohammad Amin jahani 931753e9f0 Add forwarder_hosts config to scope upstream forwarder routing per host 2026-05-10 14:58:54 +03:00
Mohammad Amin jahani e00873557a rotate script IDs when one returns a bad response 2026-05-10 01:00:46 +03:00
denuitt1 ea1b4a93f8 Revise README for clarity and additional information
Updated README to include new project details and usage instructions.
2026-05-08 13:52:48 -07:00
denuitt1 bd258c2a43 Update README.md 2026-05-08 13:48:44 -07:00
lapp 85368fcf45 Update CA Certificate name 2026-05-07 02:36:35 -07:00
lapp c6548bc33b Update .gitignore 2026-05-07 02:33:57 -07:00
lapp c26342e581 Update .gitignore 2026-05-07 02:23:50 -07:00
denuitt1 c67e20ba78 Update README_FA.md
update project structure refs
2026-05-07 02:17:11 -07:00
denuitt1 0302613e99 Update README.md 2026-05-07 02:09:00 -07:00
denuitt1 1947752863 Update README.md 2026-05-06 15:47:02 -07:00
denuitt1 c300918be0 Revise README with updated setup and usage instructions
Updated README to include new setup instructions and clarify usage.
2026-05-06 15:45:27 -07:00
denuitt1 85ed343dd1 Merge pull request #120 from onlymaj/add-issue-templates
chore: add GitHub issue templates
2026-05-06 15:41:11 -07:00
denuitt1 fe022e59ae Revise README for clarity and project updates
Updated README to reflect project changes and added detailed usage instructions.
2026-05-06 15:39:52 -07:00
lapp 07325bb451 remove script/ 2026-05-06 15:36:51 -07:00
lapp fd21ab6490 Add github actions 2026-05-06 15:35:43 -07:00
lapp 4011f2fe07 Add deploy/upstream-forwarder 2026-05-06 15:35:16 -07:00
lapp e5f7b35580 Add deploy/cloudflare-worker/worker.js 2026-05-06 15:34:57 -07:00
lapp 566e0b0aa5 Add deploy/gas/Code.gs 2026-05-06 15:34:36 -07:00
lapp caf921c372 Update .gitignore 2026-05-06 15:34:03 -07:00
denuitt1 b7fe357dda Update README.md 2026-05-06 10:02:56 -07:00
denuitt1 236eae64b9 Update README.md 2026-05-06 10:02:19 -07:00
denuitt1 41f944cc56 Revise README with project relocation notice
Updated project information and added a warning about the project's new location.
2026-05-06 09:55:41 -07:00
denuitt1 95db3f6724 Update README.md 2026-05-06 09:54:15 -07:00
Mohammad Amin jahani 98c508af98 chore: convert bug report to issue form with required inputs 2026-05-06 15:22:38 +03:00
Mohammad Amin jahani 4ad07aebd2 chore: split version / OS / Python prompts in bug report 2026-05-06 15:21:59 +03:00
Mohammad Amin jahani f66d4e3252 chore: add GitHub issue templates 2026-05-06 15:16:33 +03:00
denuitt1 a6243a8152 Update README.md
Updated project name and added detailed usage instructions.
2026-05-05 08:33:30 -07:00
denuitt1 ea42d03bdc Add VPN/Tunnel mode to TODO list 2026-05-04 05:32:56 -07:00
denuitt1 5a60479657 Add task to re-upload tutorial video with compression 2026-05-04 05:06:46 -07:00
denuitt1 1d42b62039 Update README_FA.md 2026-05-04 05:01:09 -07:00
denuitt1 6af9d6b638 Update README_FA.md 2026-05-04 05:00:43 -07:00
lapp 16f53bb5e5 Update README.md 2026-05-03 14:56:53 -07:00
lapp 85d7ddc4f7 Update README.md 2026-05-03 14:55:56 -07:00
lapp 373f90fc0f Merge branch 'main' of https://github.com/denuitt1/mhr-cfw 2026-05-03 14:49:59 -07:00
lapp 4b83efa56d add LICENSE 2026-05-03 14:48:30 -07:00
denuitt1 64ab3d6510 Update TODO 2026-05-03 14:44:29 -07:00
lapp 3cc82e7d96 Update docker-compose.yml 2026-05-03 14:43:22 -07:00
lapp 06506e3975 Update Dockerfile 2026-05-03 14:43:08 -07:00
lapp 5e6f2ca72f Update TODO.md 2026-05-03 14:35:02 -07:00
lapp dc40d6045c TODO.md 2026-05-03 14:34:36 -07:00
denuitt1 40933faecd Update README_FA.md 2026-05-03 14:04:35 -07:00
denuitt1 e2290d295c Handle GET and non-POST requests in worker.js 2026-05-03 13:33:11 -07:00
denuitt1 6205c9c9ef Merge pull request #71 from hrnrxb/main
multiple parallel script_ids formatting fixed in farsi readme.md
2026-05-03 13:23:23 -07:00
denuitt1 64d0712817 Update Cloudflare Worker file version to v2.0 2026-05-03 13:17:26 -07:00
denuitt1 06e1deabe1 Merge pull request #80 from onlymaj/upstream-forwarder
feat(be): optional upstream forwarder for stable worker exit IP
2026-05-03 13:16:15 -07:00
Mohammad Amin jahani 40d7c6c23b optional upstream forwarder for stable worker exit IP 2026-05-03 14:32:26 +03:00
Hamid a1468801ab multiple parallel script_ids formatting fixed in farsi readme.md 2026-05-02 21:27:21 +03:30
denuitt1 810f4c8792 Update worker.js 2026-05-02 04:07:01 -07:00
denuitt1 0a63cd7322 Update Code.gs 2026-05-02 04:06:35 -07:00
denuitt1 69ef6deab8 Update README.md 2026-05-01 09:45:57 -07:00
denuitt1 ae0f486296 Update constants.py 2026-05-01 09:24:45 -07:00
denuitt1 8aaad9cbfe Update README_FA.md 2026-05-01 05:09:18 -07:00
denuitt1 fad33c8793 Update README_FA.md 2026-05-01 05:06:48 -07:00
denuitt1 524b9e11dd Update README_FA.md 2026-04-30 12:16:57 -07:00
denuitt1 174e8c3409 Update README_FA.md 2026-04-30 12:15:32 -07:00
denuitt1 b3c27073cc Merge pull request #20 from 0xRadikal/docs/readme-update
Complate README_FA.md
2026-04-30 12:12:27 -07:00
Radikak bc0ca62147 Complate README_FA.md 2026-04-30 18:28:39 +04:00
26 changed files with 2016 additions and 227 deletions
+46
View File
@@ -0,0 +1,46 @@
name: Bug report
description: Something is broken in the proxy
title: "bug: "
labels: ["bug"]
body:
- type: input
id: version
attributes:
label: Version
placeholder: "e.g. 1.4.2 or commit abc1234"
validations:
required: true
- type: input
id: os
attributes:
label: OS
placeholder: "e.g. Windows 11, macOS 14, Ubuntu 22.04"
validations:
required: true
- type: input
id: python
attributes:
label: Python version
placeholder: "e.g. 3.11.7"
validations:
required: true
- type: textarea
id: what
attributes:
label: What happened
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
- type: textarea
id: logs
attributes:
label: DEBUG log excerpt (redact `auth_key` and `script_id`)
render: text
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Setup help and general questions
url: https://github.com/denuitt1/mhr-cfw#readme
about: Read the README first — it covers setup, AUTH_KEY / WORKER_URL wiring, and `--scan`.
+10
View File
@@ -0,0 +1,10 @@
---
name: Feature request
about: Suggest an improvement
title: 'feat: '
labels: enhancement
---
**Problem:**
**Proposal:**
+10
View File
@@ -0,0 +1,10 @@
---
name: Other
about: Anything that doesn't fit the templates above
title: ''
labels: ''
---
**What's this about:**
**Details:**
@@ -0,0 +1,17 @@
---
name: Site not working
about: A specific site fails through the proxy (other sites are fine)
title: 'site: '
labels: site-issue
---
**URL:**
**Works without the proxy:** yes / no
**What you see (error, blank page, broken assets, etc.):**
**DEBUG log excerpt for the failing request (redacted):**
```
```
View File
View File
+9 -11
View File
@@ -1,10 +1,12 @@
# Secrets & user config
config.json
# ignore secrets and user config
.env
# CA certificates (generated at runtime, contain private keys)
config.json
ca/
cert/
# ignore python venv
.venv/
venv/
env/
# Python
__pycache__/
@@ -15,11 +17,7 @@ __pycache__/
dist/
build/
*.egg
# Virtual environments
venv/
.venv/
env/
*.spec
# IDE
.vscode/
@@ -34,4 +32,4 @@ env/
Thumbs.db
# Temp MITM certs
domainfront_certs_*/
domainfront_certs_*/
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.13-slim
WORKDIR /app
COPY config.json .
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8085 1080
CMD ["python", "main.py"]
+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.
+218 -21
View File
@@ -1,37 +1,38 @@
# MHR-CFW - MasterHttpRelay + Cloudflare Worker
# MHR-CFW
[![GitHub](https://img.shields.io/badge/GitHub-MasterHttpRelayVPN-blue?logo=github)](https://github.com/denuitt1/mhr-cfw)
### MITM Domain-Fronted HTTP Relay + Cloudflare Worker Exit
[![GitHub](https://img.shields.io/badge/GitHub-MHR_CFW-red?logo=github)](https://github.com/denuitt1/mhr-cfw)
| [English](README.md) | [Persian](README_FA.md) |
| :---: | :---: |
## Disclaimer
`mhr-cfw` is provided for educational, testing, and research purposes only.
- **Provided without warranty:** This software is provided "AS IS", without express or implied warranty, including merchantability, fitness for a particular purpose, and non-infringement.
- **Limitation of liability:** The developers and contributors are not responsible for any direct, indirect, incidental, consequential, or other damages resulting from the use of this project or the inability to use it.
- **User responsibility:** Running this project outside controlled test environments may affect networks, accounts, proxies, certificates, or connected systems. You are solely responsible for installation, configuration, and use.
- **Legal compliance:** You are responsible for complying with all local, national, and international laws and regulations before using this software.
- **Google services compliance:** If you use Google Apps Script or other Google services with this project, you are responsible for complying with Google's Terms of Service, acceptable use rules, quotas, and platform policies. Misuse may lead to suspension or termination of your Google account or deployments.
- **License terms:** Use, copying, distribution, and modification of this software are governed by the repository license. Any use outside those terms is prohibited.
---
## How It Works
### 1 - GAS + Cloudflare Worker Exit
```
Client -> Local Proxy -> Google/CDN front -> GoogleAppsScript (GAS) Relay -> Cloudflare Worker -> Target website
|
+-> shows www.google.com to the network DPI filter
Client -> Local Relay -> Google/CDN Front -> GAS (Google Apps Script) Relay -> Cloudflare Worker -> Exit
|
+-> Shows www.google.com to network DPI filter
```
### 2 - GAS + Cloudflare Worker Middle + Self-Hosted Upstream Forwarder Relay Exit
```
Client -> Local Relay -> Google/CDN Front -> GAS (Google Apps Script) Relay -> Cloudflare Worker -> Self-Hosted Upstream Forwarder -> Exit
|
+-> Shows www.google.com to network DPI filter
```
In normal use, the browser sends traffic to the proxy running on your computer.
The proxy sends that traffic through Google-facing infrastructure so the network only sees an allowed domain such as `www.google.com`.
Your deployed relay then fetches the real website through cloudflare worker and sends the response back through the same path.
This means the filter sees normal-looking Google traffic, while the actual destination stays hidden inside the relay request.
---
## How to Use
@@ -55,7 +56,7 @@ pip install -r requirements.txt
2. From the sidebar, navigate to **Compute > Workers & Pages**
3. Click **Create Application**, Choose **Start with Hello World** and click on **Deploy**
4. Click on **Edit code** and **Delete** all the default code in the editor.
5. Open the [`worker.js`](script/worker.js) file from this project (under `script/`), **copy everything**, and paste it into the Apps Script editor.
5. Open the [`worker.js`](deploy/cloudflare-worker/worker.js) file from this project (under `deploy/`), **copy everything**, and paste it into the Apps Script editor.
6. **Important:** Change the worker on this line to the worker you created:
```javascript
const WORKER_URL = "myworker.workers.dev";
@@ -67,11 +68,11 @@ pip install -r requirements.txt
1. Open [Google Apps Script](https://script.google.com/) and sign in with your Google account.
2. Click **New project**.
3. **Delete** all the default code in the editor.
4. Open the [`Code.gs`](script/Code.gs) file from this project (under `script/`), **copy everything**, and paste it into the Apps Script editor.
4. Open the [`Code.gs`](deploy/gas/Code.gs) file from this project (under `deploy/`), **copy everything**, and paste it into the Apps Script editor.
5. **Important:** Change the password on this line to something only you know, also replace the worker url with your cloudflare worker:
```javascript
const AUTH_KEY = "your-secret-password-here";
const WORKER_URL "https://myworker.workers.dev";
const WORKER_URL = "https://myworker.workers.dev";
```
6. Click **Deploy** → **New deployment**.
7. Choose **Web app** as the type.
@@ -90,10 +91,206 @@ Click on the `run.bat` file (on windows) or `run.sh` file (on linux) to start th
If you're running for the first time it will prompt a setup wizard where you have to enter the AUTH_KEY and Google Apps Script Deployment ID.
You should see a message saying the HTTP proxy is running on `127.0.0.1:8085`
You can use [FoxyProxy](https://getfoxyproxy.org/) [Chrome Extension](https://chromewebstore.google.com/detail/foxyproxy/gcknhkkoolaabfmlnjonogaaifnjlfnp?hl=en) or [Firefox Extension](https://addons.mozilla.org/en-US/firefox/addon/foxyproxy-standard/) to use this proxy in your browser.
### 5 - Usage
### 5 - Test your connection
We recommend using [v2rayN client](https://github.com/2dust/v2rayn) and configuring a socks5 proxy.
You can also use [FoxyProxy](https://getfoxyproxy.org/)'s [Chrome extension](https://chromewebstore.google.com/detail/foxyproxy/gcknhkkoolaabfmlnjonogaaifnjlfnp?hl=en) or [Firefox extension](https://addons.mozilla.org/en-US/firefox/addon/foxyproxy-standard/) to use this proxy in your browser.
### 6 - Test your connection
Open [ipleak.net](https://ipleak.net) in your browser, you should see your ip address set as cloudflare's.
<img width="1454" height="869" alt="image" src="https://github.com/user-attachments/assets/dfd3316d-69b6-4b0e-b564-fdb055dbdafd" />
### 7 - Additional Usage Guides
#### Using the Proxy Inside a Virtual Machine
When you run a virtual machine (VM), it operates in an isolated network environment separate from the host. By default, the VM cannot directly access services running on `localhost` of the host machine — including this proxy.
To fix this, you need to find the gateway IP that your hypervisor assigns to the host, then use it instead of `localhost` when configuring the proxy inside the VM.
**Example: VirtualBox (NAT mode)**
The host is always reachable from inside the VM at `10.0.2.2`. Set the proxy:
```bash
export http_proxy="http://10.0.2.2:8085"
export https_proxy="http://10.0.2.2:8085"
export all_proxy="socks5://10.0.2.2:8085"
```
To make this permanent, add the lines above to `~/.bashrc` and run `source ~/.bashrc`.
Since this proxy performs SSL inspection, you may see certificate errors. Install the included `ca.crt` to fix them:
```bash
sudo cp ca.crt /usr/local/share/ca-certificates/ && sudo update-ca-certificates
```
---
#### Sharing the Proxy Over a Local Network (e.g. Mobile Devices)
You can use this proxy on your phone or any other device on the same network — no extra software needed.
**1. Find your host IP**
```bash
# Windows
ipconfig
# Linux / macOS
ip addr
```
Look for the IP of the adapter connected to your router (e.g. `192.168.1.8`).
**2. Forward the port (Windows only, if the service is bound to localhost)**
Run `CMD` as Administrator:
```cmd
netsh interface portproxy add v4tov4 listenaddress=192.168.1.8 listenport=8085 connectaddress=127.0.0.1 connectport=8085
netsh advfirewall firewall add rule name="Proxy 8085" dir=in action=allow protocol=TCP localport=8085
```
**3. Configure proxy on your phone**
Connect your phone to the same Wi-Fi, then set the proxy manually:
- **Host:** your host IP (e.g. `192.168.1.8`)
- **Port:** `8085`
On Android: **Settings → Wi-Fi → Modify → Proxy → Manual**
On iPhone: **Settings → Wi-Fi → (network) → HTTP Proxy → Manual**
**4. Install the CA certificate**
Transfer `ca.crt` to your phone, then:
- **Android:** Settings → Security → Install a certificate → CA certificate
- **iPhone:** Open the file → install profile → Settings → General → About → Certificate Trust Settings → enable it
---
## Optional: Stable Exit IP via Upstream Forwarder
CAPTCHAs (Cloudflare Turnstile/bot challenge, reCAPTCHA, hCaptcha) bind tokens
to the IP that solved the challenge. Cloudflare Workers exit through different
edge IPs per request, so verification on the target site fails even when you
solve the challenge. This optional add-on lets the Worker forward all `fetch()`
calls through a small Node server you run on a VPS with a stable IP — giving
the target site one consistent exit address.
### When you need this
- Sites behind Cloudflare's bot challenge keep looping you back to the challenge page.
- Login forms reject you after solving a reCAPTCHA/hCaptcha.
- You need cookie continuity across requests (e.g. `cf_clearance`).
If you don't hit these, leave it unconfigured — the Worker behaves exactly as before.
### Why a separate server is required
Cloudflare Workers don't expose a stable outbound IP — `fetch()` exits through a rotating pool of Cloudflare edge IPs, which is exactly what breaks IP-bound CAPTCHA tokens. Cloudflare's static-egress options (BYOIP, Egress Workers) are Enterprise-tier, so a small VPS with a static IP is the practical workaround. The forwarder is just a thin proxy that re-issues the `fetch()` from a stable address.
### 1. Deploy the forwarder on a VPS
The reference implementation is [`deploy/upstream-forwarder/upstream_forwarder.js`](deploy/upstream-forwarder/upstream_forwarder.js).
It needs Node 18+ and no dependencies. Run it behind Caddy or nginx with TLS —
the Worker rejects non-HTTPS forwarder URLs.
```bash
# On your VPS (Ubuntu/Debian example):
sudo apt install -y nodejs # must be 18+
export AUTH_KEY="some-long-random-string-at-least-32-chars"
export PORT=8787
node deploy/upstream-forwarder/upstream_forwarder.js
```
Front it with Caddy for auto-TLS:
```
forwarder.example.com {
reverse_proxy 127.0.0.1:8787
}
```
Quick smoke test:
```bash
curl -X POST https://forwarder.example.com/fwd \
-H "x-upstream-auth: $AUTH_KEY" \
-H "content-type: application/json" \
-d '{"u":"https://httpbin.org/ip","m":"GET","h":{}}'
```
The decoded response body should show the **VPS's IP**.
### 2. Wire the Worker to the forwarder
In the Cloudflare dashboard → your Worker → **Settings → Variables and Secrets**:
| Name | Type | Value |
|---|---|---|
| `UPSTREAM_FORWARDER_URL` | Secret | `https://forwarder.example.com/fwd` |
| `UPSTREAM_AUTH_KEY` | Secret | the same `AUTH_KEY` you set on the VPS |
| `UPSTREAM_FAIL_MODE` | Variable | `closed` (default) — return 502 on forwarder failure. Use `open` to fall back to direct fetch. |
| `UPSTREAM_TIMEOUT_MS` | Variable (optional) | default `25000` |
Save and redeploy the Worker.
### 3. Verify
Browse `https://httpbin.org/ip` through the proxy — you should see the **VPS's IP**, not Cloudflare's. Then revisit a CAPTCHA-protected site that wasn't working — the challenge should now validate.
> The forwarder must require auth. Without `AUTH_KEY` it refuses to start. Anyone with the URL and key can use it as a relay, so keep both secret.
### 4. Scope the forwarder to specific hosts (optional)
By default every request the Worker handles routes through the forwarder, so unrelated traffic also burns VPS bandwidth. To send only the sites that need a stable exit IP through the VPS, list them in `forwarder_hosts` in `config.json` — same syntax as `bypass_hosts` (exact hostname or `.suffix`). Anything not matched falls back to direct `fetch()` on the Worker.
```json
{
...
"forwarder_hosts": [
"example.com",
".cf-protected-suffix"
]
...
}
```
Leave the list empty (or remove the key) to keep the historical "forward everything" behavior.
---
## Disclaimer
`MHR-CFW` is provided for educational, testing, and research purposes only.
- **Provided without warranty:** This software is provided "AS IS", without express or implied warranty, including merchantability, fitness for a particular purpose, and non-infringement.
- **Limitation of liability:** The developers and contributors are not responsible for any direct, indirect, incidental, consequential, or other damages resulting from the use of this project or the inability to use it.
- **User responsibility:** Running this project outside controlled test environments may affect networks, accounts, proxies, certificates, or connected systems. You are solely responsible for installation, configuration, and use.
- **Legal compliance:** You are responsible for complying with all local, national, and international laws and regulations before using this software.
- **Google services compliance:** If you use Google Apps Script or other Google services with this project, you are responsible for complying with Google's Terms of Service, acceptable use rules, quotas, and platform policies. Misuse may lead to suspension or termination of your Google account or deployments.
- **License terms:** Use, copying, distribution, and modification of this software are governed by the repository license. Any use outside those terms is prohibited.
---
## Contributors
- Special thanks to [onlymaj](https://github.com/onlymaj)
---
## Sources
- This project is based on [MasterHttpRelayVPN](https://github.com/masterking32/MasterHttpRelayVPN)
---
## License
[MIT](LICENSE)
+979 -53
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
## TODO
- add vpn / tunnel mode
- add the ability for GAS Relay to be able to exit through regular web proxies
- add a self-hosted exit node (cloudflare alternative)
- tcp-over-http and udp-over-http simulation for ability to send raw packets
+1
View File
@@ -60,6 +60,7 @@
".lan",
".home.arpa"
],
"forwarder_hosts": [],
"direct_google_exclude": [
"gemini.google.com",
"aistudio.google.com",
+181
View File
@@ -0,0 +1,181 @@
// Cloudflare Worker
const WORKER_URL = "myworker.workers.dev";
const DEFAULT_UPSTREAM_TIMEOUT_MS = 25000;
export default {
async fetch(request, env) {
try {
const hop = request.headers.get("x-relay-hop");
const fwdHop = request.headers.get("x-fwd-hop");
if (hop === "1" || fwdHop === "1") {
return json({ e: "loop detected" }, 508);
}
if (request.method === "GET") {
return json({ e: "Relay is Active." }, 200);
}
if (request.method !== "POST") {
return json({ e: "Method not allowed." }, 405);
}
const req = await request.json();
if (!req.u) {
return json({ e: "missing url" }, 400);
}
const targetUrl = new URL(req.u);
const BLOCKED_HOSTS = [
WORKER_URL,
];
if (BLOCKED_HOSTS.some(h => targetUrl.hostname.endsWith(h))) {
return json({ e: "self-fetch blocked" }, 400);
}
const upstreamUrl = (env && env.UPSTREAM_FORWARDER_URL) || "";
// f === 1: forward; f === 0: skip; missing: legacy client → forward (compat).
const wantForward = (req.f === 1) || (req.f === undefined);
if (upstreamUrl && wantForward) {
const upstreamResp = await forwardViaUpstream(req, env, upstreamUrl);
if (upstreamResp) return upstreamResp;
// fall through to direct fetch only when fail-mode is open
}
const headers = new Headers();
if (req.h && typeof req.h === "object") {
for (const [k, v] of Object.entries(req.h)) {
headers.set(k, v);
}
}
headers.set("x-relay-hop", "1");
const fetchOptions = {
method: (req.m || "GET").toUpperCase(),
headers,
redirect: req.r === false ? "manual" : "follow"
};
if (req.b) {
fetchOptions.body = Uint8Array.from(atob(req.b), c => c.charCodeAt(0));
}
const resp = await fetch(targetUrl.toString(), fetchOptions);
// Read response safely (no stack overflow)
const buffer = await resp.arrayBuffer();
const uint8 = new Uint8Array(buffer);
let binary = "";
const chunkSize = 0x8000; // prevent call stack overflow
for (let i = 0; i < uint8.length; i += chunkSize) {
binary += String.fromCharCode.apply(
null,
uint8.subarray(i, i + chunkSize)
);
}
const base64 = btoa(binary);
const responseHeaders = {};
resp.headers.forEach((v, k) => {
responseHeaders[k] = v;
});
return json({
s: resp.status,
h: responseHeaders,
b: base64
});
} catch (err) {
return json({ e: String(err) }, 500);
}
}
};
async function forwardViaUpstream(req, env, upstreamUrl) {
const failMode = (env.UPSTREAM_FAIL_MODE || "closed").toLowerCase();
const timeoutMs = parseInt(env.UPSTREAM_TIMEOUT_MS, 10) || DEFAULT_UPSTREAM_TIMEOUT_MS;
const authKey = env.UPSTREAM_AUTH_KEY || "";
let parsed;
try {
parsed = new URL(upstreamUrl);
} catch (_) {
return upstreamFailure("invalid UPSTREAM_FORWARDER_URL", failMode);
}
if (parsed.protocol !== "https:") {
return upstreamFailure("UPSTREAM_FORWARDER_URL must be https://", failMode);
}
if (parsed.hostname.endsWith(WORKER_URL)) {
return upstreamFailure("self-forward blocked", failMode);
}
if (!authKey) {
return upstreamFailure("UPSTREAM_AUTH_KEY missing", failMode);
}
const payload = {
u: req.u,
m: req.m,
h: req.h,
b: req.b,
ct: req.ct,
r: req.r
};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const resp = await fetch(upstreamUrl, {
method: "POST",
headers: {
"content-type": "application/json",
"x-upstream-auth": authKey
},
body: JSON.stringify(payload),
signal: controller.signal
});
if (!resp.ok) {
return upstreamFailure("forwarder status " + resp.status, failMode);
}
// Pass body straight through without parsing — saves CPU and memory.
const body = await resp.text();
return new Response(body, {
status: 200,
headers: { "content-type": "application/json" }
});
} catch (err) {
return upstreamFailure(String(err && err.message || err), failMode);
} finally {
clearTimeout(timer);
}
}
function upstreamFailure(reason, failMode) {
if (failMode === "open") {
console.warn("upstream forwarder failed (falling back to direct):", reason);
return null; // signals caller to fall through to direct fetch
}
return json({ e: "upstream forwarder failed: " + reason }, 502);
}
function json(obj, status = 200) {
return new Response(JSON.stringify(obj), {
status,
headers: {
"content-type": "application/json"
}
});
}
+6 -14
View File
@@ -1,15 +1,4 @@
/**
* DomainFront Relay — Google Apps Script With Cloudflare Worker Exit
*
* FLOW:
* Client → GAS (Google Apps Script) → CFW (Cloudflare Worker) → Internet
*
* MODES:
* 1. Single: POST { k, m, u, h, b, ct, r } → { s, h, b }
* 2. Batch: POST { k, q: [{m,u,h,b,ct,r}, ...] } → { q: [{s,h,b}, ...] }
*
* CHANGE THESE:
*/
// Google Apps Script
const AUTH_KEY = "STRONG_SECRET_KEY";
const WORKER_URL = "https://example.workers.dev";
@@ -116,7 +105,7 @@ function _buildWorkerPayload(req) {
}
}
return {
var out = {
u: req.u,
m: (req.m || "GET").toUpperCase(),
h: headers,
@@ -124,6 +113,9 @@ function _buildWorkerPayload(req) {
ct: req.ct || null,
r: req.r !== false
};
if (typeof req.f === "number") out.f = req.f;
return out;
}
function doGet(e) {
@@ -139,4 +131,4 @@ function _json(obj) {
return ContentService
.createTextOutput(JSON.stringify(obj))
.setMimeType(ContentService.MimeType.JSON);
}
}
+2
View File
@@ -0,0 +1,2 @@
DOMAIN=example.com
LETSENCRYPT_EMAIL=user1234@example.com
+14
View File
@@ -0,0 +1,14 @@
FROM node:18-alpine
WORKDIR /app
COPY upstream_forwarder.js .
ENV NODE_ENV=production
ENV PORT=8787
ENV HOST=0.0.0.0
EXPOSE 8787
CMD ["node", "upstream_forwarder.js"]
@@ -0,0 +1,113 @@
# docker-compose.yml
name: "mhr-cfw-upstream-forwarder-cluster"
services:
traefik:
image: traefik:v3.6
container_name: traefik
restart: unless-stopped
security_opt:
- no-new-privileges:true
env_file:
- .env
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./traefik.yml:/traefik.yml:ro"
- "./data/letsencrypt/acme.json:/letsencrypt/acme.json"
networks:
- traefik-network
ports:
- 80:80
- 443:443
# - 8080:8080
command:
- "--configFile=/traefik.yml"
labels:
- "traefik.enable=true"
- "traefik.http.routers.dashboard.rule=Host(`traefik.${DOMAIN}`)"
- "traefik.http.routers.dashboard.entrypoints=web,websecure"
- "traefik.http.routers.dashboard.service=api@internal"
- "traefik.http.routers.dashboard.tls=true"
- "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
- "traefik.docker.network=traefik-network"
portainer:
image: portainer/portainer-ce:lts
container_name: portainer
restart: unless-stopped
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
- "portainer-data:/data"
networks:
- traefik-network
labels:
- "traefik.enable=true"
- "traefik.http.routers.portainer.rule=Host(`portainer.${DOMAIN}`)"
- "traefik.http.routers.portainer.entrypoints=web,websecure"
- "traefik.http.routers.portainer.tls=true"
- "traefik.http.routers.portainer.tls.certresolver=letsencrypt"
- "traefik.http.services.portainer.loadbalancer.server.port=9000"
- "traefik.docker.network=traefik-network"
mhr-cfw-upstream-forwarder-node1:
image: mhr-cfw-upstream-forwarder-node1
build: ./services/mhr-cfw-upstream-forwarder/.
container_name: mhr-cfw-upstream-forwarder-node1
restart: unless-stopped
networks:
- traefik-network
environment:
AUTH_KEY: "YOUR_SECRET_KEY" # replace with your own secret key
PORT: 8787
HOST: 0.0.0.0
labels:
- "traefik.enable=true"
- "traefik.http.routers.mhr-cfw-upstream-forwarder-node1.rule=Host(`node1.${DOMAIN}`)"
- "traefik.http.routers.mhr-cfw-upstream-forwarder-node1.entrypoints=web,websecure"
- "traefik.http.routers.mhr-cfw-upstream-forwarder-node1.tls=true"
- "traefik.http.routers.mhr-cfw-upstream-forwarder-node1.tls.certresolver=letsencrypt"
- "traefik.http.services.mhr-cfw-upstream-forwarder-node1.loadbalancer.server.port=8787"
- "traefik.docker.network=traefik-network"
# Optional: basic healthcheck
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8787/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
mhr-cfw-upstream-forwarder-node2:
image: mhr-cfw-upstream-forwarder-node2
build: ./services/mhr-cfw-upstream-forwarder/.
container_name: mhr-cfw-upstream-forwarder-node2
restart: unless-stopped
networks:
- traefik-network
environment:
AUTH_KEY: "YOUR_SECRET_KEY" # replace with your own secret key
PORT: 8787
HOST: 0.0.0.0
labels:
- "traefik.enable=true"
- "traefik.http.routers.mhr-cfw-upstream-forwarder-node2.rule=Host(`node2.${DOMAIN}`)"
- "traefik.http.routers.mhr-cfw-upstream-forwarder-node2.entrypoints=web,websecure"
- "traefik.http.routers.mhr-cfw-upstream-forwarder-node2.tls=true"
- "traefik.http.routers.mhr-cfw-upstream-forwarder-node2.tls.certresolver=letsencrypt"
- "traefik.http.services.mhr-cfw-upstream-forwarder-node2.loadbalancer.server.port=8787"
- "traefik.docker.network=traefik-network"
# Optional: basic healthcheck
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8787/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
portainer-data:
name: portainer-data
external: false
networks:
traefik-network:
name: traefik-network
driver: bridge
external: true
+38
View File
@@ -0,0 +1,38 @@
# traefik.yml
global:
checkNewVersion: true
sendAnonymousUsage: true
log:
level: DEBUG
api:
insecure: false
dashboard: true
providers:
docker:
#watch: true
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
network: traefik-network
entryPoints:
web:
address: ":80"
# http:
# redirections:
# entryPoint:
# to: websecure
# scheme: https
websecure:
address: ":443"
certificatesResolvers:
letsencrypt:
acme:
email: ${LETSENCRYPT_EMAIL}
storage: /letsencrypt/acme.json
httpChallenge:
entryPoint: web
@@ -0,0 +1,143 @@
// Upstream Forwarder — single-file Node 18+ HTTP server.
//
// Purpose: Provide a stable exit IP for the Cloudflare Worker relay so
// CAPTCHA tokens (Turnstile, reCAPTCHA, hCaptcha) bound to the solving
// IP survive verification on the target site.
//
// Run on a VPS with a stable public IP. Expose behind Caddy/nginx with
// TLS — the Worker rejects non-HTTPS forwarder URLs.
//
// Required env:
// AUTH_KEY — must match the Worker's UPSTREAM_AUTH_KEY (>= 32 chars)
//
// Optional env:
// PORT — listen port (default 8787)
// HOST — listen host (default 127.0.0.1, so Caddy/nginx fronts it)
//
// Wire protocol matches main/services/cloudflare-worker/worker.js:
// POST /fwd body: { u, m, h, b, ct, r } → { s, h, b } or { e }
"use strict";
const http = require("http");
const AUTH_KEY = process.env.AUTH_KEY || "";
const PORT = parseInt(process.env.PORT, 10) || 8787;
const HOST = process.env.HOST || "127.0.0.1";
if (!AUTH_KEY || AUTH_KEY.length < 32) {
console.error("FATAL: AUTH_KEY env var missing or shorter than 32 chars.");
process.exit(1);
}
// Mirrors SKIP_HEADERS in main/script/Code.gs:6-9.
const SKIP_HEADERS = new Set([
"host",
"connection",
"content-length",
"transfer-encoding",
"proxy-connection",
"proxy-authorization"
]);
const STATUS_PAGE =
"<!DOCTYPE html><html><head><title>Forwarder Active</title></head>" +
'<body style="font-family:sans-serif;max-width:600px;margin:40px auto">' +
'<h1>Forwarder <span style="color:#16a34a;font-weight:700">Active</span></h1>' +
"<p>Upstream forwarder for the relay Worker.</p>" +
"</body></html>";
const server = http.createServer(async (req, res) => {
try {
if (req.method === "GET" && (req.url === "/" || req.url === "")) {
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
res.end(STATUS_PAGE);
return;
}
if (req.method !== "POST" || req.url !== "/fwd") {
sendJson(res, 404, { e: "not found" });
return;
}
if (req.headers["x-upstream-auth"] !== AUTH_KEY) {
sendJson(res, 401, { e: "unauthorized" });
return;
}
const raw = await readBody(req);
let body;
try {
body = JSON.parse(raw);
} catch (_) {
sendJson(res, 400, { e: "invalid json" });
return;
}
if (!body.u || typeof body.u !== "string" || !/^https?:\/\//i.test(body.u)) {
sendJson(res, 400, { e: "bad url" });
return;
}
const headers = {};
if (body.h && typeof body.h === "object") {
for (const [k, v] of Object.entries(body.h)) {
if (typeof v !== "string") continue;
if (SKIP_HEADERS.has(k.toLowerCase())) continue;
headers[k] = v;
}
}
headers["x-fwd-hop"] = "1";
const fetchOptions = {
method: (body.m || "GET").toUpperCase(),
headers,
redirect: body.r === false ? "manual" : "follow"
};
if (body.b) {
fetchOptions.body = Buffer.from(body.b, "base64");
}
let resp;
try {
resp = await fetch(body.u, fetchOptions);
} catch (err) {
sendJson(res, 502, { e: "fetch failed: " + String(err && err.message || err) });
return;
}
const buf = Buffer.from(await resp.arrayBuffer());
const responseHeaders = {};
resp.headers.forEach((v, k) => {
responseHeaders[k] = v;
});
sendJson(res, 200, {
s: resp.status,
h: responseHeaders,
b: buf.toString("base64")
});
} catch (err) {
sendJson(res, 500, { e: String(err && err.message || err) });
}
});
server.listen(PORT, HOST, () => {
console.log("upstream_forwarder listening on " + HOST + ":" + PORT);
});
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on("data", c => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
function sendJson(res, status, obj) {
const body = JSON.stringify(obj);
res.writeHead(status, { "content-type": "application/json" });
res.end(body);
}
+18
View File
@@ -0,0 +1,18 @@
name: "mhr-cfw-docker"
services:
mhr-cfw:
image: .
container_name: "mhr-cfw"
restart: unless-stopped
networks:
- mhr-cfw-proxy
ports:
- "8085:8085" # http port
- "1080:1080" # socks5 port
networks:
mhr-cfw-proxy:
name: mhr-cfw-proxy
driver: bridge
external: false
-88
View File
@@ -1,88 +0,0 @@
const WORKER_URL = "myworker.workers.dev";
export default {
async fetch(request) {
try {
if (request.headers.get("x-relay-hop") === "1") {
return json({ e: "loop detected" }, 508);
}
const req = await request.json();
if (!req.u) {
return json({ e: "missing url" }, 400);
}
const targetUrl = new URL(req.u);
const BLOCKED_HOSTS = [
WORKER_URL,
];
if (BLOCKED_HOSTS.some(h => targetUrl.hostname.endsWith(h))) {
return json({ e: "self-fetch blocked" }, 400);
}
const headers = new Headers();
if (req.h && typeof req.h === "object") {
for (const [k, v] of Object.entries(req.h)) {
headers.set(k, v);
}
}
headers.set("x-relay-hop", "1");
const fetchOptions = {
method: (req.m || "GET").toUpperCase(),
headers,
redirect: req.r === false ? "manual" : "follow"
};
if (req.b) {
const binary = Uint8Array.from(atob(req.b), c => c.charCodeAt(0));
fetchOptions.body = binary;
}
const resp = await fetch(targetUrl.toString(), fetchOptions);
// Read response safely (no stack overflow)
const buffer = await resp.arrayBuffer();
const uint8 = new Uint8Array(buffer);
let binary = "";
const chunkSize = 0x8000; // prevent call stack overflow
for (let i = 0; i < uint8.length; i += chunkSize) {
binary += String.fromCharCode.apply(
null,
uint8.subarray(i, i + chunkSize)
);
}
const base64 = btoa(binary);
const responseHeaders = {};
resp.headers.forEach((v, k) => {
responseHeaders[k] = v;
});
return json({
s: resp.status,
h: responseHeaders,
b: base64
});
} catch (err) {
return json({ e: String(err) }, 500);
}
}
};
function json(obj, status = 200) {
return new Response(JSON.stringify(obj), {
status,
headers: {
"content-type": "application/json"
}
});
}
+1 -1
View File
@@ -20,7 +20,7 @@ import tempfile
log = logging.getLogger("Cert")
CERT_NAME = "mhr-cfw"
CERT_NAME = "MHR-CFW"
# ─────────────────────────────────────────────────────────────────────────────
+2 -2
View File
@@ -8,7 +8,7 @@ overridden from `config.json` where noted.
from __future__ import annotations
# ── Version ───────────────────────────────────────────────────────────────
__version__ = "1.1.0"
__version__ = "2.0.1"
# ── Size caps ─────────────────────────────────────────────────────────────
@@ -224,4 +224,4 @@ STATEFUL_HEADER_NAMES: tuple[str, ...] = (
UNCACHEABLE_HEADER_NAMES: tuple[str, ...] = (
"cookie", "authorization", "proxy-authorization", "range",
"if-none-match", "if-modified-since", "cache-control", "pragma",
)
)
+163 -37
View File
@@ -60,6 +60,10 @@ class HostStat:
errors: int = 0
class _RelayBadResponse(Exception):
"""Raised when a relay response indicates the chosen script ID is unhealthy."""
def _build_sni_pool(front_domain: str, overrides: list | None) -> list[str]:
"""Build the list of SNIs to rotate through on new outbound TLS handshakes.
@@ -150,6 +154,10 @@ class DomainFronter:
minimum=1024,
)
self._forwarder_hosts = self._load_host_rules(
config.get("forwarder_hosts", [])
)
# Connection pool — TTL-based, pre-warmed, with concurrency control
self._pool: list[tuple[asyncio.StreamReader, asyncio.StreamWriter, float]] = []
self._pool_lock = asyncio.Lock()
@@ -224,6 +232,33 @@ class DomainFronter:
value = default
return max(minimum, value)
@staticmethod
def _load_host_rules(raw) -> tuple[set[str], tuple[str, ...]]:
"""Parse host strings into (exact_set, suffix_tuple). Mirrors ProxyServer._load_host_rules."""
exact: set[str] = set()
suffixes: list[str] = []
for item in raw or []:
h = str(item).strip().lower().rstrip(".")
if not h:
continue
if h.startswith("."):
suffixes.append(h)
else:
exact.add(h)
return exact, tuple(suffixes)
@staticmethod
def _host_matches_rules(host: str,
rules: tuple[set[str], tuple[str, ...]]) -> bool:
exact, suffixes = rules
h = host.lower().rstrip(".")
if h in exact:
return True
for s in suffixes:
if h.endswith(s):
return True
return False
def _ssl_ctx(self) -> ssl.SSLContext:
ctx = ssl.create_default_context()
if certifi is not None:
@@ -401,6 +436,16 @@ class DomainFronter:
if force or until <= now:
self._sid_blacklist.pop(sid, None)
def _next_alt_sid(self, tried: set[str]) -> str | None:
"""Pick a script ID not already tried and not blacklisted, or None."""
for sid in self._script_ids:
if sid in tried:
continue
if self._is_sid_blacklisted(sid):
continue
return sid
return None
def _pick_fanout_sids(self, key: str | None) -> list[str]:
"""Pick up to `parallel_relay` distinct non-blacklisted script IDs.
@@ -842,8 +887,18 @@ class DomainFronter:
{"m": "HEAD", "u": "http://example.com/", "k": self.auth_key}
).encode()
hdrs = {"content-type": "application/json"}
sid = self._script_ids[0]
for sid in list(self._script_ids):
if self._is_sid_blacklisted(sid):
continue
if await self._prewarm_one_sid(sid, payload, hdrs):
return
self._blacklist_sid(sid, reason="prewarm")
log.debug("Pre-warm exhausted all script IDs")
async def _prewarm_one_sid(self, sid: str, payload: bytes,
hdrs: dict) -> bool:
"""Try /dev fast-path detection then /exec warmup for one sid."""
# Test /dev endpoint — returns data inline (no 302 redirect).
# If it works, saves ~400ms per request by eliminating one round trip.
try:
@@ -857,19 +912,21 @@ class DomainFronter:
timeout=15,
)
dt = (time.perf_counter() - t0) * 1000
data = json.loads(body.decode(errors="replace"))
if "s" in data:
self._dev_available = True
log.info("/dev fast path active (%.0fms, no redirect)", dt)
return
if status == 200:
data = json.loads(body.decode(errors="replace"))
if "s" in data:
self._dev_available = True
log.info("/dev fast path active (%.0fms, no redirect)", dt)
return True
except Exception as e:
log.debug("/dev test failed: %s", e)
log.debug("/dev test failed for sid %s: %s",
sid[-8:] if len(sid) > 8 else sid, e)
# Fallback: warm up with /exec
try:
exec_path = f"/macros/s/{sid}/exec"
t0 = time.perf_counter()
await asyncio.wait_for(
status, _, _ = await asyncio.wait_for(
self._h2.request(
method="POST", path=exec_path, host=self.http_host,
headers=hdrs, body=payload,
@@ -877,9 +934,16 @@ class DomainFronter:
timeout=15,
)
dt = (time.perf_counter() - t0) * 1000
if status != 200:
log.debug("Pre-warm /exec returned %d for sid %s",
status, sid[-8:] if len(sid) > 8 else sid)
return False
log.info("Apps Script pre-warmed in %.0fms", dt)
return True
except Exception as e:
log.debug("Pre-warm failed: %s", e)
log.debug("Pre-warm failed for sid %s: %s",
sid[-8:] if len(sid) > 8 else sid, e)
return False
async def _keepalive_loop(self):
"""Send periodic pings to keep Apps Script warm + H2 connection alive."""
@@ -1515,6 +1579,13 @@ class DomainFronter:
ct = headers.get("Content-Type") or headers.get("content-type")
if ct:
payload["ct"] = ct
# Only emit 'f' when scoped; Worker treats missing 'f' as forward (legacy compat).
exact, suffixes = self._forwarder_hosts
if exact or suffixes:
host = urlparse(url).hostname or ""
payload["f"] = 1 if self._host_matches_rules(
host, self._forwarder_hosts
) else 0
return payload
@classmethod
@@ -1665,6 +1736,15 @@ class DomainFronter:
async def _relay_with_retry(self, payload: dict) -> bytes:
"""Single relay with one retry on failure. Uses H2 if available."""
attempts = self._retry_attempts_for_payload(payload)
host_key = self._host_key(payload.get("u"))
tried_sids: set[str] = set()
def pick_sid() -> str:
if not tried_sids:
return self._script_id_for_key(host_key)
alt = self._next_alt_sid(tried_sids)
return alt if alt is not None else self._script_id_for_key(host_key)
# Fan-out: race N Apps Script instances when enabled and H2 is up.
# Cuts tail latency when one container is slow/cold. Only kicks in
# if multiple script IDs are configured and the H2 transport is live.
@@ -1686,12 +1766,23 @@ class DomainFronter:
# Try HTTP/2 first — much faster (multiplexed, no pool checkout)
if self._h2_available():
for attempt in range(attempts):
sid = pick_sid()
tried_sids.add(sid)
try:
result = await asyncio.wait_for(
self._relay_single_h2(payload), timeout=self._relay_timeout
self._relay_single_h2(payload, sid=sid),
timeout=self._relay_timeout,
)
self._record_h2_success()
return result
except _RelayBadResponse as e:
self._blacklist_sid(sid, reason=str(e)[:40])
if (attempt < attempts - 1
and self._next_alt_sid(tried_sids) is not None):
log.debug("H2 sid %s bad (%s), rotating",
sid[-8:] if len(sid) > 8 else sid, e)
continue
raise
except Exception as e:
self._record_h2_failure(e)
if attempt < attempts - 1:
@@ -1716,10 +1807,21 @@ class DomainFronter:
# HTTP/1.1 fallback (pool-based)
async with self._semaphore:
for attempt in range(attempts):
sid = pick_sid()
tried_sids.add(sid)
try:
return await asyncio.wait_for(
self._relay_single(payload), timeout=self._relay_timeout
self._relay_single(payload, sid=sid),
timeout=self._relay_timeout,
)
except _RelayBadResponse as e:
self._blacklist_sid(sid, reason=str(e)[:40])
if (attempt < attempts - 1
and self._next_alt_sid(tried_sids) is not None):
log.debug("H1 sid %s bad (%s), rotating",
sid[-8:] if len(sid) > 8 else sid, e)
continue
raise
except Exception as e:
if attempt < attempts - 1:
log.debug("Relay attempt %d failed (%s: %s), retrying",
@@ -1776,33 +1878,15 @@ class DomainFronter:
if pending:
await asyncio.gather(*pending, return_exceptions=True)
async def _relay_single_h2(self, payload: dict) -> bytes:
async def _relay_single_h2(self, payload: dict,
sid: str | None = None) -> bytes:
"""Execute a relay through HTTP/2 multiplexing.
Uses the shared H2 connection — no pool checkout needed.
Many concurrent calls all share one TLS connection.
"""
full_payload = dict(payload)
full_payload["k"] = self.auth_key
json_body = json.dumps(full_payload).encode()
path = self._exec_path(payload.get("u"))
status, headers, body = await self._h2.request(
method="POST", path=path, host=self.http_host,
headers={"content-type": "application/json"},
body=json_body,
)
return self._parse_relay_response(body)
async def _relay_single_h2_with_sid(self, payload: dict,
sid: str) -> bytes:
"""Execute an H2 relay pinned to a specific Apps Script deployment.
Used by `_relay_fanout` to race multiple script IDs in parallel.
Mirrors `_relay_single_h2` but ignores the stable-hash routing.
"""
if sid is None:
sid = self._script_id_for_key(self._host_key(payload.get("u")))
full_payload = dict(payload)
full_payload["k"] = self.auth_key
json_body = json.dumps(full_payload).encode()
@@ -1815,16 +1899,32 @@ class DomainFronter:
body=json_body,
)
return self._parse_relay_response(body)
if status != 200:
raise _RelayBadResponse(
f"upstream HTTP {status} from script "
f"{sid[-8:] if len(sid) > 8 else sid}",
)
return self._parse_or_raise(body)
async def _relay_single(self, payload: dict) -> bytes:
async def _relay_single_h2_with_sid(self, payload: dict,
sid: str) -> bytes:
"""Execute an H2 relay pinned to a specific Apps Script deployment.
Used by `_relay_fanout` to race multiple script IDs in parallel.
"""
return await self._relay_single_h2(payload, sid=sid)
async def _relay_single(self, payload: dict,
sid: str | None = None) -> bytes:
"""Execute a single relay POST → redirect → parse."""
# Add auth key
if sid is None:
sid = self._script_id_for_key(self._host_key(payload.get("u")))
full_payload = dict(payload)
full_payload["k"] = self.auth_key
json_body = json.dumps(full_payload).encode()
path = self._exec_path(payload.get("u"))
path = self._exec_path_for_sid(sid)
reader, writer, created = await self._acquire()
try:
@@ -1872,7 +1972,12 @@ class DomainFronter:
status, resp_headers, resp_body = await self._read_http_response(reader)
await self._release(reader, writer, created)
return self._parse_relay_response(resp_body)
if status != 200:
raise _RelayBadResponse(
f"upstream HTTP {status} from script "
f"{sid[-8:] if len(sid) > 8 else sid}",
)
return self._parse_or_raise(resp_body)
except Exception:
try:
@@ -2136,6 +2241,27 @@ class DomainFronter:
return self._parse_relay_json(data)
def _parse_or_raise(self, body: bytes) -> bytes:
"""Like `_parse_relay_response` but raises `_RelayBadResponse` on failure."""
text = body.decode(errors="replace").strip()
if not text:
raise _RelayBadResponse("empty response")
try:
data = json.loads(text)
except json.JSONDecodeError:
m = re.search(r'\{.*\}', text, re.DOTALL)
if not m:
raise _RelayBadResponse(f"non-JSON: {text[:120]}")
try:
data = json.loads(m.group())
except json.JSONDecodeError:
raise _RelayBadResponse(f"bad JSON: {text[:120]}")
if "e" in data:
raise _RelayBadResponse(f"relay error: {data['e']}")
return self._parse_relay_json(data)
def _parse_relay_json(self, data: dict) -> bytes:
"""Convert a parsed relay JSON dict to raw HTTP response bytes."""
if "e" in data: