mirror of
https://github.com/denuitt1/mhr-cfw.git
synced 2026-05-18 05:44:35 +03:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44b2481040 | |||
| 923c1bc49b | |||
| 46f60d5141 | |||
| e7582422c0 | |||
| d3378ccc8e | |||
| f21aebff09 | |||
| 75aeb90964 | |||
| d43c71b4ce | |||
| c935b87293 | |||
| b13f778cb6 | |||
| a22cc31a3f | |||
| cba912bab4 | |||
| 29cb4b5f55 | |||
| 0a58baa6ad | |||
| 931753e9f0 | |||
| e00873557a | |||
| ea1b4a93f8 | |||
| bd258c2a43 | |||
| 85368fcf45 | |||
| c6548bc33b | |||
| c26342e581 | |||
| c67e20ba78 | |||
| 0302613e99 | |||
| 1947752863 | |||
| c300918be0 | |||
| 85ed343dd1 | |||
| fe022e59ae | |||
| 07325bb451 | |||
| fd21ab6490 | |||
| 4011f2fe07 | |||
| e5f7b35580 | |||
| 566e0b0aa5 | |||
| caf921c372 | |||
| b7fe357dda | |||
| 236eae64b9 | |||
| 41f944cc56 | |||
| 95db3f6724 | |||
| 98c508af98 | |||
| 4ad07aebd2 | |||
| f66d4e3252 | |||
| a6243a8152 | |||
| ea42d03bdc | |||
| 5a60479657 | |||
| 1d42b62039 | |||
| 6af9d6b638 | |||
| 16f53bb5e5 | |||
| 85d7ddc4f7 | |||
| 373f90fc0f | |||
| 4b83efa56d | |||
| 64ab3d6510 | |||
| 3cc82e7d96 | |||
| 06506e3975 | |||
| 5e6f2ca72f | |||
| dc40d6045c |
@@ -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
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an improvement
|
||||
title: 'feat: '
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
**Problem:**
|
||||
|
||||
**Proposal:**
|
||||
@@ -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):**
|
||||
|
||||
```
|
||||
```
|
||||
+9
-11
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -1,37 +1,38 @@
|
||||
# MHR-CFW - MasterHttpRelay + Cloudflare Worker
|
||||
# MHR-CFW
|
||||
|
||||
[](https://github.com/denuitt1/mhr-cfw)
|
||||
### MITM Domain-Fronted HTTP Relay + Cloudflare Worker Exit
|
||||
|
||||
[](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.
|
||||
@@ -103,6 +104,76 @@ Open [ipleak.net](https://ipleak.net) in your browser, you should see your ip ad
|
||||
<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
|
||||
@@ -128,7 +199,7 @@ Cloudflare Workers don't expose a stable outbound IP — `fetch()` exits through
|
||||
|
||||
### 1. Deploy the forwarder on a VPS
|
||||
|
||||
The reference implementation is [`script/upstream_forwarder.js`](script/upstream_forwarder.js).
|
||||
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.
|
||||
|
||||
@@ -137,7 +208,7 @@ the Worker rejects non-HTTPS forwarder URLs.
|
||||
sudo apt install -y nodejs # must be 18+
|
||||
export AUTH_KEY="some-long-random-string-at-least-32-chars"
|
||||
export PORT=8787
|
||||
node script/upstream_forwarder.js
|
||||
node deploy/upstream-forwarder/upstream_forwarder.js
|
||||
```
|
||||
|
||||
Front it with Caddy for auto-TLS:
|
||||
@@ -178,8 +249,48 @@ Browse `https://httpbin.org/ip` through the proxy — you should see the **VPS's
|
||||
|
||||
> 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.
|
||||
|
||||
---
|
||||
|
||||
## Sources for this project
|
||||
- https://github.com/masterking32/MasterHttpRelayVPN
|
||||
## 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)
|
||||
|
||||
+110
-9
@@ -2,7 +2,7 @@
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
[](https://github.com/denuitt1/mhr-cfw)
|
||||
[](https://github.com/denuitt1/mhr-cfw)
|
||||
|
||||
| [English](README.md) | [Persian](README_FA.md) |
|
||||
| :---: | :---: |
|
||||
@@ -13,7 +13,7 @@
|
||||
[](https://youtu.be/L3lJZrAqqUQ)
|
||||
|
||||
- لینک یوتیوب: https://youtu.be/L3lJZrAqqUQ
|
||||
- لینک داخلی دانلود ویدیو: https://nc.thearthur.ir/s/YaCp4zAzepHJKi2
|
||||
- لینک داخلی دانلود ویدیو: https://cdn.vayrex.ir/vasls/8440130/1777611424961-86c092e3-mhrv-cfw.mp4
|
||||
|
||||
---
|
||||
|
||||
@@ -190,7 +190,7 @@ Cloudflare Worker نقش «خروجی» ترافیک را دارد — درخو
|
||||
> در ویندوز: `Ctrl+A` سپس `Delete`
|
||||
> در مک: `Cmd+A` سپس `Delete`
|
||||
|
||||
**۹.** فایل `script/worker.js` را از پوشه پروژه با یک ویرایشگر متن باز کنید (مثلاً Notepad، VS Code، یا Gedit).
|
||||
**۹.** فایل `deploy/cloudflare-worker/worker.js` را از پوشه پروژه با یک ویرایشگر متن باز کنید (مثلاً Notepad، VS Code، یا Gedit).
|
||||
|
||||
**۱۰.** تمام محتوای آن را کپی (`Ctrl+A` سپس `Ctrl+C`) و داخل ویرایشگر Cloudflare **Paste** کنید (`Ctrl+V`).
|
||||
|
||||
@@ -219,7 +219,7 @@ Apps Script نقش «دروازه» را دارد — درخواستهای پ
|
||||
|
||||
**۳.** در ویرایشگر بازشده، تمام کد پیشفرض (`function myFunction() {}`) را **حذف** کنید.
|
||||
|
||||
**۴.** فایل `script/Code.gs` را از پوشه پروژه با یک ویرایشگر متن باز کرده، کل محتوا را کپی و داخل ویرایشگر Apps Script **Paste** کنید.
|
||||
**۴.** فایل `deploy/gas/Code.gs` را از پوشه پروژه با یک ویرایشگر متن باز کرده، کل محتوا را کپی و داخل ویرایشگر Apps Script **Paste** کنید.
|
||||
|
||||
**۵. مهم:** این دو خط را پیدا کنید:
|
||||
```javascript
|
||||
@@ -453,6 +453,77 @@ Port : 1080
|
||||
|
||||
---
|
||||
|
||||
|
||||
## راهنماهای تکمیلی (اختیاری)
|
||||
|
||||
#### استفاده از پروکسی در ماشین مجازی
|
||||
|
||||
وقتی یک ماشین مجازی (VM) اجرا میکنید، در یک محیط شبکهای ایزوله نسبت به سیستم هاست قرار میگیرد. به همین دلیل، VM به طور پیشفرض نمیتواند به سرویسهایی که روی `localhost` سیستم هاست اجرا میشوند دسترسی داشته باشد — از جمله این پروکسی.
|
||||
|
||||
برای حل این مشکل، باید IP گیتویای که هایپروایزر به هاست اختصاص میدهد را پیدا کنید و به جای `localhost` از آن استفاده کنید.
|
||||
|
||||
**مثال: VirtualBox (حالت NAT)**
|
||||
|
||||
در این حالت، سیستم هاست همیشه از داخل VM از طریق آدرس `10.0.2.2` در دسترس است. پروکسی را اینطور تنظیم کنید:
|
||||
|
||||
```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"
|
||||
```
|
||||
|
||||
برای دائمی شدن، این خطوط را به `bashrc.` اضافه کرده و `source ~/.bashrc` را اجرا کنید.
|
||||
|
||||
از آنجایی که این پروکسی SSL Inspection انجام میدهد، ممکن است با خطای certificate مواجه شوید. برای رفع آن، فایل `ca.crt` موجود در پروژه را نصب کنید:
|
||||
|
||||
```bash
|
||||
sudo cp ca.crt /usr/local/share/ca-certificates/ && sudo update-ca-certificates
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### اشتراکگذاری پروکسی در شبکه محلی (مثلاً گوشی موبایل)
|
||||
|
||||
میتوانید از این پروکسی روی گوشی یا هر دستگاه دیگری در همان شبکه استفاده کنید — بدون نیاز به نرمافزار اضافی.
|
||||
|
||||
**۱. پیدا کردن IP سیستم هاست**
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
ipconfig
|
||||
|
||||
# Linux / macOS
|
||||
ip addr
|
||||
```
|
||||
|
||||
آدرس IP سیستمی که به مودم وصل است را پیدا کنید (مثلاً `192.168.1.8`).
|
||||
|
||||
**۲. Port Forward (فقط ویندوز، اگر سرویس روی localhost اجرا میشود)**
|
||||
|
||||
CMD را به عنوان 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
|
||||
```
|
||||
|
||||
**۳. تنظیم پروکسی روی گوشی**
|
||||
|
||||
گوشی را به همان Wi-Fi وصل کنید، سپس پروکسی را به صورت دستی تنظیم کنید:
|
||||
- **Host:** IP سیستم هاست (مثلاً `192.168.1.8`)
|
||||
- **Port:** `8085`
|
||||
|
||||
|
||||
- Android: **Settings → Wi-Fi → Modify → Proxy → Manual**
|
||||
- iPhone: **Settings → Wi-Fi → (شبکه) → HTTP Proxy → Manual**
|
||||
|
||||
**۴. نصب CA Certificate**
|
||||
|
||||
فایل `ca.crt` را به گوشی منتقل کنید، سپس:
|
||||
|
||||
- **Android:** Settings → Security → Install a certificate → CA certificate
|
||||
- **iPhone:** (باز کردن فایل) → Settings → General → VPN & Device Management → Install → (فعال سازی) → General → About → Certificate Trust Settings
|
||||
|
||||
## اختیاری — IP خروجی پایدار با Upstream Forwarder
|
||||
|
||||
سایتهایی که از CAPTCHA استفاده میکنند (Cloudflare Turnstile، reCAPTCHA، hCaptcha) توکن حلشده را به IP بازکنندهی چالش گره میزنند. Cloudflare Worker در هر درخواست از IP متفاوتی خروج میگیرد، بنابراین حتی پس از حل CAPTCHA، تأیید سمت سرور رد میشود. این افزونهی اختیاری به Worker اجازه میدهد همهی `fetch()` ها را از طریق یک سرور Node کوچک روی VPS شما (با IP ثابت) عبور دهد — بهطوری که سایت مقصد همیشه یک IP خروجی ثابت ببیند.
|
||||
@@ -471,14 +542,14 @@ Cloudflare Worker آیپی خروجی ثابتی ندارد — هر `fetch()
|
||||
|
||||
### ۱. اجرای forwarder روی VPS
|
||||
|
||||
پیادهسازی مرجع در فایل [`script/upstream_forwarder.js`](script/upstream_forwarder.js) قرار دارد. به Node نسخه ۱۸+ نیاز دارد و هیچ وابستگی خارجی ندارد. آن را پشت Caddy یا nginx با TLS اجرا کنید — Worker آدرسهای غیر HTTPS را نمیپذیرد.
|
||||
پیادهسازی مرجع در فایل [`deploy/upstream-forwarder/upstream_forwarder.js`](deploy/upstream-forwarder/upstream_forwarder.js) قرار دارد. به Node نسخه ۱۸+ نیاز دارد و هیچ وابستگی خارجی ندارد. آن را پشت Caddy یا nginx با TLS اجرا کنید — Worker آدرسهای غیر HTTPS را نمیپذیرد.
|
||||
|
||||
```bash
|
||||
# روی VPS (مثال Ubuntu/Debian):
|
||||
sudo apt install -y nodejs # باید نسخه ۱۸ یا بالاتر باشد
|
||||
export AUTH_KEY="یک-کلید-تصادفی-حداقل-۳۲-کاراکتر"
|
||||
export PORT=8787
|
||||
node script/upstream_forwarder.js
|
||||
node deploy/upstream-forwarder/upstream_forwarder.js
|
||||
```
|
||||
|
||||
تنظیم Caddy برای TLS خودکار:
|
||||
@@ -569,6 +640,10 @@ curl -X POST https://forwarder.example.com/fwd \
|
||||
|
||||
با `parallel_relay: 3`، پروکسی هر درخواست را به **هر سه** script به صورت همزمان میفرستد و سریعترین پاسخ را برمیگرداند — این latency را به شدت کاهش میدهد.
|
||||
|
||||
شما همچنین میتوانید `parallel_relay` را روی 1 قرار دهید تا همزمان فقط از یک script استفاده شود و محدودیت درخواست های شما افزایش یابد.
|
||||
|
||||
|
||||
|
||||
### تنظیمات دانلود موازی (Parallel Download)
|
||||
|
||||
برای فایلهای بزرگ، پروکسی از قابلیت HTTP Range استفاده میکند تا چندین قطعه را به صورت موازی دانلود کند:
|
||||
@@ -915,9 +990,17 @@ mhr-cfw/
|
||||
├── ca/ ← گواهی CA (خودکار ساخته میشود)
|
||||
│ ├── ca.crt ← گواهی عمومی CA
|
||||
│ └── ca.key ← کلید خصوصی CA (محرمانه)
|
||||
├── script/
|
||||
│ ├── Code.gs ← کد Google Apps Script
|
||||
│ └── worker.js ← کد Cloudflare Worker
|
||||
├── deploy/
|
||||
| ├── gas/
|
||||
| └── Code.gs ← کد Google Apps Script
|
||||
| ├── cloudflare-worker/
|
||||
| └── worker.js ← کد Cloudflare Worker
|
||||
| ├── upstream-forwarder/
|
||||
| ├── .env
|
||||
| ├── Dockerfile
|
||||
| ├── docker-compose.yml
|
||||
| ├── traefik.yml
|
||||
| └── upstream-forwarder.js
|
||||
└── src/
|
||||
├── proxy_server.py ← سرور HTTP/SOCKS5 محلی
|
||||
├── domain_fronter.py ← موتور رله Apps Script
|
||||
@@ -933,6 +1016,24 @@ mhr-cfw/
|
||||
|
||||
> **هرگز** فایل `ca/ca.key` را به کسی ندهید یا در اینترنت آپلود نکنید.
|
||||
|
||||
### ۴. محدود کردن forwarder به میزبانهای خاص (اختیاری)
|
||||
|
||||
بهصورت پیشفرض همهٔ درخواستهایی که Worker پردازش میکند از طریق forwarder عبور میکنند، در نتیجه ترافیک غیرمرتبط هم پهنای باند VPS را مصرف میکند. اگر فقط میخواهید سایتهایی که به IP خروجی پایدار نیاز دارند از مسیر VPS رد شوند، آنها را در `forwarder_hosts` در `config.json` فهرست کنید — همان نحو `bypass_hosts` (نام دقیق دامنه یا الگوی `.suffix`). هر چه با این لیست تطبیق نخورد، روی Worker با `fetch()` مستقیم ارسال میشود.
|
||||
|
||||
```json
|
||||
{
|
||||
...
|
||||
"forwarder_hosts": [
|
||||
"example.com",
|
||||
".cf-protected-suffix"
|
||||
],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
اگر این لیست خالی باشد (یا کلید را حذف کنید)، رفتار قبلی یعنی «forward همه» حفظ میشود.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## سلب مسئولیت
|
||||
|
||||
@@ -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
|
||||
@@ -60,6 +60,7 @@
|
||||
".lan",
|
||||
".home.arpa"
|
||||
],
|
||||
"forwarder_hosts": [],
|
||||
"direct_google_exclude": [
|
||||
"gemini.google.com",
|
||||
"aistudio.google.com",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Google Apps Script File
|
||||
// Google Apps Script
|
||||
|
||||
const AUTH_KEY = "STRONG_SECRET_KEY";
|
||||
const WORKER_URL = "https://example.workers.dev";
|
||||
@@ -105,7 +105,7 @@ function _buildWorkerPayload(req) {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
var out = {
|
||||
u: req.u,
|
||||
m: (req.m || "GET").toUpperCase(),
|
||||
h: headers,
|
||||
@@ -113,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) {
|
||||
@@ -128,4 +131,4 @@ function _json(obj) {
|
||||
return ContentService
|
||||
.createTextOutput(JSON.stringify(obj))
|
||||
.setMimeType(ContentService.MimeType.JSON);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
DOMAIN=example.com
|
||||
LETSENCRYPT_EMAIL=user1234@example.com
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
// 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/script/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);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
// Cloudflare Worker File - v2.0
|
||||
|
||||
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) || "";
|
||||
if (upstreamUrl) {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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"
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import tempfile
|
||||
|
||||
log = logging.getLogger("Cert")
|
||||
|
||||
CERT_NAME = "mhr-cfw"
|
||||
CERT_NAME = "MHR-CFW"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
+163
-37
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user