mirror of
https://github.com/denuitt1/mhr-cfw.git
synced 2026-05-18 05:44:35 +03:00
Compare commits
71 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 | |||
| 40933faecd | |||
| e2290d295c | |||
| 6205c9c9ef | |||
| 64d0712817 | |||
| 06e1deabe1 | |||
| 40d7c6c23b | |||
| a1468801ab | |||
| 810f4c8792 | |||
| 0a63cd7322 | |||
| 69ef6deab8 | |||
| ae0f486296 | |||
| 8aaad9cbfe | |||
| fad33c8793 | |||
| 524b9e11dd | |||
| 174e8c3409 | |||
| b3c27073cc | |||
| bc0ca62147 |
@@ -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.
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -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,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"
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import tempfile
|
||||
|
||||
log = logging.getLogger("Cert")
|
||||
|
||||
CERT_NAME = "mhr-cfw"
|
||||
CERT_NAME = "MHR-CFW"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
+2
-2
@@ -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
@@ -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