mirror of
https://github.com/denuitt1/mhr-cfw.git
synced 2026-05-18 05:44:35 +03:00
Compare commits
91 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 | |||
| 6e794a03df | |||
| 906a7d2bce | |||
| dad57fc285 | |||
| 565f0ddc6e | |||
| 059ad131b1 | |||
| 57bf7f5872 | |||
| b5ad3ae546 | |||
| 51800ce9fd | |||
| 793c76ac4e | |||
| e057ef8459 | |||
| 7251afa17c | |||
| 4d20d8ddfc | |||
| aa220c720d | |||
| 06d35504b1 | |||
| 620d5d92ee | |||
| 8fc40e8c33 | |||
| e025324877 | |||
| 7576bf1acd | |||
| 11025c36fe | |||
| faea7ffde4 |
@@ -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
-9
@@ -1,9 +1,12 @@
|
||||
# Secrets & user config
|
||||
config.json
|
||||
# ignore secrets and user config
|
||||
.env
|
||||
config.json
|
||||
ca/
|
||||
|
||||
# CA certificates (generated at runtime, contain private keys)
|
||||
cert/
|
||||
# ignore python venv
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
@@ -14,15 +17,12 @@ __pycache__/
|
||||
dist/
|
||||
build/
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
*.spec
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.code-workspace
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
+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.
|
||||
@@ -83,48 +84,213 @@ pip install -r requirements.txt
|
||||
|
||||
> ⚠️ Remember the password you set in step 3. You'll use the same password in the config file below.
|
||||
|
||||
### 4 - Configure the config.json file
|
||||
|
||||
1. Copy the example config file:
|
||||
```bash
|
||||
cp config.example.json config.json
|
||||
```
|
||||
On Windows, you can also just copy & rename the file manually.
|
||||
|
||||
2. Open `config.json` in any text editor and fill in your values:
|
||||
```json
|
||||
{
|
||||
"mode": "apps_script",
|
||||
"google_ip": "216.239.38.120",
|
||||
"front_domain": "www.google.com",
|
||||
"script_id": "PASTE_YOUR_DEPLOYMENT_ID_HERE",
|
||||
"auth_key": "your-secret-password-here",
|
||||
"listen_host": "127.0.0.1",
|
||||
"listen_port": 8085,
|
||||
"socks5_enabled": true,
|
||||
"socks5_port": 1080,
|
||||
"log_level": "INFO",
|
||||
"verify_ssl": true
|
||||
}
|
||||
```
|
||||
- `script_id` → Paste the Deployment ID from Step 3.
|
||||
- `auth_key` → The **same password** you set in `Code.gs`.
|
||||
|
||||
### 4 - Run
|
||||
|
||||
Simply click on `start.bat` file (on windows) or `start.sh` (on linux).
|
||||
|
||||
or if you want to run it manually:
|
||||
```bash
|
||||
python3 main.py
|
||||
```
|
||||
Click on the `run.bat` file (on windows) or `run.sh` file (on linux) to start the relay.
|
||||
|
||||
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
|
||||
+78
-1
@@ -5,8 +5,85 @@
|
||||
"script_id": "YOUR_APPS_SCRIPT_DEPLOYMENT_ID",
|
||||
"auth_key": "CHANGE_ME_TO_A_STRONG_SECRET",
|
||||
"listen_host": "127.0.0.1",
|
||||
"socks5_enabled": true,
|
||||
"listen_port": 8085,
|
||||
"socks5_port": 1080,
|
||||
"log_level": "INFO",
|
||||
"verify_ssl": true,
|
||||
"lan_sharing": true,
|
||||
"relay_timeout": 25,
|
||||
"tls_connect_timeout": 15,
|
||||
"tcp_connect_timeout": 10,
|
||||
"max_response_body_bytes": 209715200,
|
||||
"parallel_relay": 1,
|
||||
"chunked_download_extensions": [
|
||||
".bin",
|
||||
".zip",
|
||||
".tar",
|
||||
".gz",
|
||||
".bz2",
|
||||
".xz",
|
||||
".7z",
|
||||
".rar",
|
||||
".exe",
|
||||
".msi",
|
||||
".dmg",
|
||||
".deb",
|
||||
".rpm",
|
||||
".apk",
|
||||
".iso",
|
||||
".img",
|
||||
".mp4",
|
||||
".mkv",
|
||||
".avi",
|
||||
".mov",
|
||||
".webm",
|
||||
".mp3",
|
||||
".flac",
|
||||
".wav",
|
||||
".aac",
|
||||
".pdf",
|
||||
".doc",
|
||||
".docx",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".wasm"
|
||||
],
|
||||
"chunked_download_min_size": 5242880,
|
||||
"chunked_download_chunk_size": 524288,
|
||||
"chunked_download_max_parallel": 8,
|
||||
"chunked_download_max_chunks": 256,
|
||||
"block_hosts": [],
|
||||
"bypass_hosts": [
|
||||
"localhost",
|
||||
".local",
|
||||
".lan",
|
||||
".home.arpa"
|
||||
],
|
||||
"forwarder_hosts": [],
|
||||
"direct_google_exclude": [
|
||||
"gemini.google.com",
|
||||
"aistudio.google.com",
|
||||
"notebooklm.google.com",
|
||||
"labs.google.com",
|
||||
"meet.google.com",
|
||||
"accounts.google.com",
|
||||
"ogs.google.com",
|
||||
"mail.google.com",
|
||||
"calendar.google.com",
|
||||
"drive.google.com",
|
||||
"docs.google.com",
|
||||
"chat.google.com",
|
||||
"maps.google.com",
|
||||
"play.google.com",
|
||||
"translate.google.com",
|
||||
"assistant.google.com",
|
||||
"lens.google.com"
|
||||
],
|
||||
"direct_google_allow": [
|
||||
"www.google.com",
|
||||
"safebrowsing.google.com"
|
||||
],
|
||||
"youtube_via_relay": false,
|
||||
"hosts": {}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,777 +0,0 @@
|
||||
"""
|
||||
Local HTTP proxy server.
|
||||
|
||||
Intercepts the user's browser traffic and forwards everything through
|
||||
a domain-fronted connection to a CDN worker or Apps Script relay.
|
||||
|
||||
Supports:
|
||||
- CONNECT method → WebSocket tunnel (modes 1-3) or MITM relay (apps_script)
|
||||
- GET / POST etc. → HTTP forwarding (modes 1-3) or JSON relay (apps_script)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import ssl
|
||||
import time
|
||||
|
||||
from core.domain_fronter import DomainFronter
|
||||
|
||||
log = logging.getLogger("Proxy")
|
||||
|
||||
|
||||
class ResponseCache:
|
||||
"""Simple LRU response cache — avoids repeated relay calls."""
|
||||
|
||||
def __init__(self, max_mb: int = 50):
|
||||
self._store: dict[str, tuple[bytes, float]] = {}
|
||||
self._size = 0
|
||||
self._max = max_mb * 1024 * 1024
|
||||
self.hits = 0
|
||||
self.misses = 0
|
||||
|
||||
def get(self, url: str) -> bytes | None:
|
||||
entry = self._store.get(url)
|
||||
if not entry:
|
||||
self.misses += 1
|
||||
return None
|
||||
raw, expires = entry
|
||||
if time.time() > expires:
|
||||
self._size -= len(raw)
|
||||
del self._store[url]
|
||||
self.misses += 1
|
||||
return None
|
||||
self.hits += 1
|
||||
return raw
|
||||
|
||||
def put(self, url: str, raw_response: bytes, ttl: int = 300):
|
||||
size = len(raw_response)
|
||||
if size > self._max // 4 or size == 0:
|
||||
return
|
||||
# Evict oldest to make room
|
||||
while self._size + size > self._max and self._store:
|
||||
oldest = next(iter(self._store))
|
||||
self._size -= len(self._store[oldest][0])
|
||||
del self._store[oldest]
|
||||
if url in self._store:
|
||||
self._size -= len(self._store[url][0])
|
||||
self._store[url] = (raw_response, time.time() + ttl)
|
||||
self._size += size
|
||||
|
||||
@staticmethod
|
||||
def parse_ttl(raw_response: bytes, url: str) -> int:
|
||||
"""Determine cache TTL from response headers and URL."""
|
||||
hdr_end = raw_response.find(b"\r\n\r\n")
|
||||
if hdr_end < 0:
|
||||
return 0
|
||||
hdr = raw_response[:hdr_end].decode(errors="replace").lower()
|
||||
|
||||
# Don't cache errors or non-200
|
||||
if b"HTTP/1.1 200" not in raw_response[:20]:
|
||||
return 0
|
||||
if "no-store" in hdr:
|
||||
return 0
|
||||
|
||||
# Explicit max-age
|
||||
m = re.search(r"max-age=(\d+)", hdr)
|
||||
if m:
|
||||
return min(int(m.group(1)), 86400)
|
||||
|
||||
# Heuristic by content type / extension
|
||||
path = url.split("?")[0].lower()
|
||||
static_exts = (
|
||||
".css", ".js", ".woff", ".woff2", ".ttf", ".eot",
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico",
|
||||
".mp3", ".mp4", ".wasm",
|
||||
)
|
||||
for ext in static_exts:
|
||||
if path.endswith(ext):
|
||||
return 3600 # 1 hour for static assets
|
||||
|
||||
ct_m = re.search(r"content-type:\s*([^\r\n]+)", hdr)
|
||||
ct = ct_m.group(1) if ct_m else ""
|
||||
if "image/" in ct or "font/" in ct:
|
||||
return 3600
|
||||
if "text/css" in ct or "javascript" in ct:
|
||||
return 1800
|
||||
if "text/html" in ct or "application/json" in ct:
|
||||
return 0 # don't cache dynamic content by default
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
class ProxyServer:
|
||||
def __init__(self, config: dict):
|
||||
self.host = config.get("listen_host", "127.0.0.1")
|
||||
self.port = config.get("listen_port", 8080)
|
||||
self.mode = config.get("mode", "domain_fronting")
|
||||
self.fronter = DomainFronter(config)
|
||||
self.mitm = None
|
||||
self._cache = ResponseCache(max_mb=50)
|
||||
|
||||
# Persistent HTTP tunnel cache for google_fronting mode
|
||||
# Key: "host:port" → (tunnel_reader, tunnel_writer, lock)
|
||||
self._http_tunnels: dict = {}
|
||||
self._tunnel_lock = asyncio.Lock()
|
||||
|
||||
# hosts override — DNS fake-map: domain/suffix → IP
|
||||
# Checked before any real DNS lookup; supports exact and suffix matching.
|
||||
self._hosts: dict[str, str] = config.get("hosts", {})
|
||||
|
||||
if self.mode == "apps_script":
|
||||
try:
|
||||
from core.mitm import MITMCertManager
|
||||
self.mitm = MITMCertManager()
|
||||
except ImportError:
|
||||
log.error("apps_script mode requires 'cryptography' package.")
|
||||
log.error("Run: pip install cryptography")
|
||||
raise SystemExit(1)
|
||||
|
||||
async def start(self):
|
||||
srv = await asyncio.start_server(self._on_client, self.host, self.port)
|
||||
log.info(
|
||||
"Listening on %s:%d — configure your browser HTTP proxy to this address",
|
||||
self.host, self.port,
|
||||
)
|
||||
async with srv:
|
||||
await srv.serve_forever()
|
||||
|
||||
# ── client handler ────────────────────────────────────────────
|
||||
|
||||
async def _on_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
addr = writer.get_extra_info("peername")
|
||||
try:
|
||||
first_line = await asyncio.wait_for(reader.readline(), timeout=30)
|
||||
if not first_line:
|
||||
return
|
||||
|
||||
# Read remaining headers
|
||||
header_block = first_line
|
||||
while True:
|
||||
line = await asyncio.wait_for(reader.readline(), timeout=10)
|
||||
header_block += line
|
||||
if line in (b"\r\n", b"\n", b""):
|
||||
break
|
||||
|
||||
request_line = first_line.decode(errors="replace").strip()
|
||||
parts = request_line.split(" ", 2)
|
||||
if len(parts) < 2:
|
||||
return
|
||||
|
||||
method = parts[0].upper()
|
||||
|
||||
if method == "CONNECT":
|
||||
await self._do_connect(parts[1], reader, writer)
|
||||
else:
|
||||
await self._do_http(header_block, reader, writer)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
log.debug("Timeout: %s", addr)
|
||||
except Exception as e:
|
||||
log.error("Error (%s): %s", addr, e)
|
||||
finally:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── CONNECT (HTTPS tunnelling) ────────────────────────────────
|
||||
|
||||
async def _do_connect(self, target: str, reader, writer):
|
||||
host, _, port = target.rpartition(":")
|
||||
port = int(port) if port else 443
|
||||
if not host:
|
||||
host, port = target, 443
|
||||
|
||||
log.info("CONNECT → %s:%d", host, port)
|
||||
|
||||
writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||
await writer.drain()
|
||||
|
||||
if self.mode == "apps_script":
|
||||
override_ip = self._sni_rewrite_ip(host)
|
||||
if override_ip:
|
||||
# SNI-blocked domain: MITM-decrypt from browser, then
|
||||
# re-connect to the override IP with SNI=front_domain so
|
||||
# the ISP never sees the blocked hostname in the TLS handshake.
|
||||
log.info("SNI-rewrite tunnel → %s via %s (SNI: %s)",
|
||||
host, override_ip, self.fronter.sni_host)
|
||||
await self._do_sni_rewrite_tunnel(host, port, reader, writer,
|
||||
connect_ip=override_ip)
|
||||
elif self._is_google_domain(host):
|
||||
log.info("Direct tunnel → %s (Google domain, skipping relay)", host)
|
||||
await self._do_direct_tunnel(host, port, reader, writer)
|
||||
else:
|
||||
await self._do_mitm_connect(host, port, reader, writer)
|
||||
else:
|
||||
await self.fronter.tunnel(host, port, reader, writer)
|
||||
|
||||
# ── Hosts override (fake DNS) ─────────────────────────────────
|
||||
|
||||
# Built-in list of domains that must be reached via Google's frontend IP
|
||||
# with SNI rewritten to `front_domain` (default: www.google.com).
|
||||
# These are Google-owned services whose real SNI is DPI-blocked in some
|
||||
# countries, but that Google serves from the same edge IP as www.google.com.
|
||||
# Users don't need to configure anything — any host matching one of these
|
||||
# suffixes is transparently SNI-rewritten to the configured `google_ip`.
|
||||
# Config's "hosts" map still takes precedence (for custom overrides).
|
||||
_SNI_REWRITE_SUFFIXES = (
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
"youtube-nocookie.com",
|
||||
"ytimg.com",
|
||||
"ggpht.com",
|
||||
"gvt1.com",
|
||||
"gvt2.com",
|
||||
"doubleclick.net",
|
||||
"googlesyndication.com",
|
||||
"googleadservices.com",
|
||||
"google-analytics.com",
|
||||
"googletagmanager.com",
|
||||
"googletagservices.com",
|
||||
"fonts.googleapis.com",
|
||||
)
|
||||
|
||||
def _sni_rewrite_ip(self, host: str) -> str | None:
|
||||
"""Return the IP to SNI-rewrite `host` through, or None.
|
||||
|
||||
Order of precedence:
|
||||
1. Explicit entry in config `hosts` map (exact or suffix match).
|
||||
2. Built-in `_SNI_REWRITE_SUFFIXES` → mapped to config `google_ip`.
|
||||
"""
|
||||
ip = self._hosts_ip(host)
|
||||
if ip:
|
||||
return ip
|
||||
h = host.lower().rstrip(".")
|
||||
for suffix in self._SNI_REWRITE_SUFFIXES:
|
||||
if h == suffix or h.endswith("." + suffix):
|
||||
return self.fronter.connect_host # configured google_ip
|
||||
return None
|
||||
|
||||
def _hosts_ip(self, host: str) -> str | None:
|
||||
"""Return override IP for host if defined in config 'hosts', else None.
|
||||
|
||||
Supports exact match and suffix match (e.g. 'youtube.com' matches
|
||||
'www.youtube.com', 'm.youtube.com', etc.).
|
||||
"""
|
||||
h = host.lower().rstrip(".")
|
||||
if h in self._hosts:
|
||||
return self._hosts[h]
|
||||
# suffix match: check every parent label
|
||||
parts = h.split(".")
|
||||
for i in range(1, len(parts)):
|
||||
parent = ".".join(parts[i:])
|
||||
if parent in self._hosts:
|
||||
return self._hosts[parent]
|
||||
return None
|
||||
|
||||
# ── Google domain detection ───────────────────────────────────
|
||||
|
||||
# Only domains whose SNI the ISP does NOT block — direct tunnel is safe.
|
||||
# YouTube/googlevideo SNIs are blocked; they go through _do_sni_rewrite_tunnel
|
||||
# via the hosts map instead.
|
||||
_GOOGLE_SUFFIXES = (
|
||||
".google.com", ".google.co",
|
||||
".googleapis.com", ".gstatic.com",
|
||||
".googleusercontent.com",
|
||||
)
|
||||
_GOOGLE_EXACT = {
|
||||
"google.com", "gstatic.com", "googleapis.com",
|
||||
}
|
||||
|
||||
def _is_google_domain(self, host: str) -> bool:
|
||||
"""Return True if host is a Google-owned domain."""
|
||||
h = host.lower().rstrip(".")
|
||||
if h in self._GOOGLE_EXACT:
|
||||
return True
|
||||
for suffix in self._GOOGLE_SUFFIXES:
|
||||
if h.endswith(suffix):
|
||||
return True
|
||||
return False
|
||||
|
||||
# ── Direct tunnel (no MITM) ───────────────────────────────────
|
||||
|
||||
async def _do_direct_tunnel(self, host: str, port: int,
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
connect_ip: str | None = None):
|
||||
"""Pipe raw TLS bytes directly to the target server.
|
||||
|
||||
connect_ip overrides DNS: the TCP connection goes to that IP
|
||||
while the browser's TLS (SNI=host) is piped through unchanged.
|
||||
Defaults to the configured google_ip for Google-category domains.
|
||||
"""
|
||||
target_ip = connect_ip or self.fronter.connect_host
|
||||
try:
|
||||
r_remote, w_remote = await asyncio.wait_for(
|
||||
asyncio.open_connection(target_ip, port), timeout=10
|
||||
)
|
||||
except Exception as e:
|
||||
log.error("Direct tunnel connect failed (%s via %s): %s",
|
||||
host, target_ip, e)
|
||||
return
|
||||
|
||||
async def pipe(src, dst, label):
|
||||
try:
|
||||
while True:
|
||||
data = await src.read(65536)
|
||||
if not data:
|
||||
break
|
||||
dst.write(data)
|
||||
await dst.drain()
|
||||
except (ConnectionError, asyncio.CancelledError):
|
||||
pass
|
||||
except Exception as e:
|
||||
log.debug("Pipe %s ended: %s", label, e)
|
||||
finally:
|
||||
try:
|
||||
dst.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.gather(
|
||||
pipe(reader, w_remote, f"client→{host}"),
|
||||
pipe(r_remote, writer, f"{host}→client"),
|
||||
)
|
||||
|
||||
# ── SNI-rewrite tunnel ────────────────────────────────────────
|
||||
|
||||
async def _do_sni_rewrite_tunnel(self, host: str, port: int, reader, writer,
|
||||
connect_ip: str | None = None):
|
||||
"""MITM-decrypt TLS from browser, then re-encrypt toward connect_ip
|
||||
using SNI=front_domain (e.g. www.google.com).
|
||||
|
||||
The ISP only ever sees SNI=www.google.com in the outgoing handshake,
|
||||
hiding the blocked hostname (e.g. www.youtube.com).
|
||||
"""
|
||||
target_ip = connect_ip or self.fronter.connect_host
|
||||
sni_out = self.fronter.sni_host # e.g. "www.google.com"
|
||||
|
||||
# Step 1: MITM — accept TLS from the browser
|
||||
ssl_ctx_server = self.mitm.get_server_context(host)
|
||||
loop = asyncio.get_event_loop()
|
||||
transport = writer.transport
|
||||
protocol = transport.get_protocol()
|
||||
try:
|
||||
new_transport = await loop.start_tls(
|
||||
transport, protocol, ssl_ctx_server, server_side=True,
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug("SNI-rewrite TLS accept failed (%s): %s", host, e)
|
||||
return
|
||||
writer._transport = new_transport
|
||||
|
||||
# Step 2: open outgoing TLS to target IP with the safe SNI
|
||||
ssl_ctx_client = ssl.create_default_context()
|
||||
if not self.fronter.verify_ssl:
|
||||
ssl_ctx_client.check_hostname = False
|
||||
ssl_ctx_client.verify_mode = ssl.CERT_NONE
|
||||
try:
|
||||
r_out, w_out = await asyncio.wait_for(
|
||||
asyncio.open_connection(
|
||||
target_ip, port,
|
||||
ssl=ssl_ctx_client,
|
||||
server_hostname=sni_out,
|
||||
),
|
||||
timeout=10,
|
||||
)
|
||||
except Exception as e:
|
||||
log.error("SNI-rewrite outbound connect failed (%s via %s): %s",
|
||||
host, target_ip, e)
|
||||
return
|
||||
|
||||
# Step 3: pipe application-layer bytes between the two TLS sessions
|
||||
async def pipe(src, dst, label):
|
||||
try:
|
||||
while True:
|
||||
data = await src.read(65536)
|
||||
if not data:
|
||||
break
|
||||
dst.write(data)
|
||||
await dst.drain()
|
||||
except (ConnectionError, asyncio.CancelledError):
|
||||
pass
|
||||
except Exception as exc:
|
||||
log.debug("Pipe %s ended: %s", label, exc)
|
||||
finally:
|
||||
try:
|
||||
dst.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.gather(
|
||||
pipe(reader, w_out, f"client→{host}"),
|
||||
pipe(r_out, writer, f"{host}→client"),
|
||||
)
|
||||
|
||||
# ── MITM CONNECT (apps_script mode) ───────────────────────────
|
||||
|
||||
async def _do_mitm_connect(self, host: str, port: int, reader, writer):
|
||||
"""Intercept TLS, decrypt HTTP, and relay through Apps Script."""
|
||||
ssl_ctx = self.mitm.get_server_context(host)
|
||||
|
||||
# Upgrade the existing connection to TLS (we are the server)
|
||||
loop = asyncio.get_event_loop()
|
||||
transport = writer.transport
|
||||
protocol = transport.get_protocol()
|
||||
|
||||
try:
|
||||
new_transport = await loop.start_tls(
|
||||
transport, protocol, ssl_ctx, server_side=True,
|
||||
)
|
||||
except Exception as e:
|
||||
# Non-HTTPS traffic (e.g. MTProto, plain HTTP on port 80/443)
|
||||
# routed through the proxy will always fail TLS — log at DEBUG
|
||||
# to avoid alarming noise.
|
||||
if port != 443:
|
||||
log.debug("TLS handshake skipped for %s:%d (non-HTTPS): %s", host, port, e)
|
||||
else:
|
||||
log.debug("TLS handshake failed for %s: %s", host, e)
|
||||
return
|
||||
|
||||
# Update writer to use the new TLS transport
|
||||
writer._transport = new_transport
|
||||
|
||||
# Read and relay HTTP requests from the browser (now decrypted)
|
||||
while True:
|
||||
try:
|
||||
first_line = await asyncio.wait_for(reader.readline(), timeout=120)
|
||||
if not first_line:
|
||||
break
|
||||
|
||||
header_block = first_line
|
||||
while True:
|
||||
line = await asyncio.wait_for(reader.readline(), timeout=10)
|
||||
header_block += line
|
||||
if line in (b"\r\n", b"\n", b""):
|
||||
break
|
||||
|
||||
# Read body
|
||||
body = b""
|
||||
for raw_line in header_block.split(b"\r\n"):
|
||||
if raw_line.lower().startswith(b"content-length:"):
|
||||
length = int(raw_line.split(b":", 1)[1].strip())
|
||||
body = await reader.readexactly(length)
|
||||
break
|
||||
|
||||
# Parse the request
|
||||
request_line = first_line.decode(errors="replace").strip()
|
||||
parts = request_line.split(" ", 2)
|
||||
if len(parts) < 2:
|
||||
break
|
||||
|
||||
method = parts[0]
|
||||
path = parts[1]
|
||||
|
||||
# Parse headers
|
||||
headers = {}
|
||||
for raw_line in header_block.split(b"\r\n")[1:]:
|
||||
if b":" in raw_line:
|
||||
k, v = raw_line.decode(errors="replace").split(":", 1)
|
||||
headers[k.strip()] = v.strip()
|
||||
|
||||
# Build full URL (browser sends just the path in CONNECT)
|
||||
if port == 443:
|
||||
url = f"https://{host}{path}"
|
||||
else:
|
||||
url = f"https://{host}:{port}{path}"
|
||||
|
||||
log.info("MITM → %s %s", method, url)
|
||||
|
||||
# ── CORS: extract relevant request headers ────────────────────
|
||||
origin = next(
|
||||
(v for k, v in headers.items() if k.lower() == "origin"), ""
|
||||
)
|
||||
acr_method = next(
|
||||
(v for k, v in headers.items()
|
||||
if k.lower() == "access-control-request-method"), ""
|
||||
)
|
||||
acr_headers = next(
|
||||
(v for k, v in headers.items()
|
||||
if k.lower() == "access-control-request-headers"), ""
|
||||
)
|
||||
|
||||
# CORS preflight — respond directly; UrlFetchApp doesn't
|
||||
# support OPTIONS so forwarding it would always fail.
|
||||
if method.upper() == "OPTIONS" and acr_method:
|
||||
log.debug("CORS preflight → %s (responding locally)", url[:60])
|
||||
writer.write(self._cors_preflight_response(origin, acr_method, acr_headers))
|
||||
await writer.drain()
|
||||
continue
|
||||
|
||||
# Check local cache first (GET only)
|
||||
response = None
|
||||
if method == "GET" and not body:
|
||||
response = self._cache.get(url)
|
||||
if response:
|
||||
log.debug("Cache HIT: %s", url[:60])
|
||||
|
||||
if response is None:
|
||||
# Relay through Apps Script
|
||||
try:
|
||||
response = await self._relay_smart(method, url, headers, body)
|
||||
except Exception as e:
|
||||
log.error("Relay error (%s): %s", url[:60], e)
|
||||
err_body = f"Relay error: {e}".encode()
|
||||
response = (
|
||||
b"HTTP/1.1 502 Bad Gateway\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"Content-Length: " + str(len(err_body)).encode() + b"\r\n"
|
||||
b"\r\n" + err_body
|
||||
)
|
||||
|
||||
# Cache successful GET responses
|
||||
if method == "GET" and not body and response:
|
||||
ttl = ResponseCache.parse_ttl(response, url)
|
||||
if ttl > 0:
|
||||
self._cache.put(url, response, ttl)
|
||||
log.debug("Cached (%ds): %s", ttl, url[:60])
|
||||
|
||||
# Inject permissive CORS headers whenever the browser
|
||||
# sent an Origin (cross-origin XHR / fetch).
|
||||
if origin and response:
|
||||
response = self._inject_cors_headers(response, origin)
|
||||
|
||||
writer.write(response)
|
||||
await writer.drain()
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
except asyncio.IncompleteReadError:
|
||||
break
|
||||
except ConnectionError:
|
||||
break
|
||||
except Exception as e:
|
||||
log.error("MITM handler error (%s): %s", host, e)
|
||||
break
|
||||
|
||||
# ── CORS helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _cors_preflight_response(origin: str, acr_method: str, acr_headers: str) -> bytes:
|
||||
"""Return a 204 No Content response that satisfies a CORS preflight."""
|
||||
allow_origin = origin or "*"
|
||||
allow_methods = (
|
||||
f"{acr_method}, GET, POST, PUT, DELETE, PATCH, OPTIONS"
|
||||
if acr_method else
|
||||
"GET, POST, PUT, DELETE, PATCH, OPTIONS"
|
||||
)
|
||||
allow_headers = acr_headers or "*"
|
||||
return (
|
||||
"HTTP/1.1 204 No Content\r\n"
|
||||
f"Access-Control-Allow-Origin: {allow_origin}\r\n"
|
||||
f"Access-Control-Allow-Methods: {allow_methods}\r\n"
|
||||
f"Access-Control-Allow-Headers: {allow_headers}\r\n"
|
||||
"Access-Control-Allow-Credentials: true\r\n"
|
||||
"Access-Control-Max-Age: 86400\r\n"
|
||||
"Vary: Origin\r\n"
|
||||
"Content-Length: 0\r\n"
|
||||
"\r\n"
|
||||
).encode()
|
||||
|
||||
@staticmethod
|
||||
def _inject_cors_headers(response: bytes, origin: str) -> bytes:
|
||||
"""Inject CORS headers only if the upstream response lacks them.
|
||||
|
||||
We must NOT overwrite the origin server's CORS headers: sites like
|
||||
x.com return carefully-scoped Access-Control-Allow-Headers that list
|
||||
specific custom headers (e.g. x-csrf-token). Replacing them with
|
||||
wildcards together with Allow-Credentials: true makes browsers
|
||||
reject the response (per the Fetch spec, "*" is literal when
|
||||
credentials are included), which the site then blames on privacy
|
||||
extensions. So we only fill in what the server omitted.
|
||||
"""
|
||||
sep = b"\r\n\r\n"
|
||||
if sep not in response:
|
||||
return response
|
||||
header_section, body = response.split(sep, 1)
|
||||
lines = header_section.decode(errors="replace").split("\r\n")
|
||||
|
||||
existing = {ln.split(":", 1)[0].strip().lower()
|
||||
for ln in lines if ":" in ln}
|
||||
|
||||
# If the upstream already handled CORS, leave it completely alone.
|
||||
if "access-control-allow-origin" in existing:
|
||||
return response
|
||||
|
||||
# Otherwise inject a minimal, credential-safe set (no wildcards,
|
||||
# since wildcards combined with credentials are invalid).
|
||||
allow_origin = origin or "*"
|
||||
additions = [f"Access-Control-Allow-Origin: {allow_origin}"]
|
||||
if allow_origin != "*":
|
||||
additions.append("Access-Control-Allow-Credentials: true")
|
||||
additions.append("Vary: Origin")
|
||||
return ("\r\n".join(lines + additions) + "\r\n\r\n").encode() + body
|
||||
|
||||
async def _relay_smart(self, method, url, headers, body):
|
||||
"""Choose optimal relay strategy based on request type.
|
||||
|
||||
- GET requests for likely-large downloads use parallel-range.
|
||||
- All other requests (API calls, HTML, JSON, XHR) go through the
|
||||
single-request relay. This avoids injecting a synthetic Range
|
||||
header on normal traffic, which some origins honor by returning
|
||||
206 — breaking fetch()/XHR on sites like x.com or Cloudflare
|
||||
challenge pages.
|
||||
"""
|
||||
if method == "GET" and not body:
|
||||
# Respect client's own Range header verbatim.
|
||||
if headers:
|
||||
for k in headers:
|
||||
if k.lower() == "range":
|
||||
return await self.fronter.relay(
|
||||
method, url, headers, body
|
||||
)
|
||||
# Only probe with Range when the URL looks like a big file.
|
||||
if self._is_likely_download(url, headers):
|
||||
return await self.fronter.relay_parallel(
|
||||
method, url, headers, body
|
||||
)
|
||||
return await self.fronter.relay(method, url, headers, body)
|
||||
|
||||
def _is_likely_download(self, url: str, headers: dict) -> bool:
|
||||
"""Heuristic: is this URL likely a large file download?"""
|
||||
# Check file extension
|
||||
path = url.split("?")[0].lower()
|
||||
large_exts = {
|
||||
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
|
||||
".exe", ".msi", ".dmg", ".deb", ".rpm", ".apk",
|
||||
".iso", ".img",
|
||||
".mp4", ".mkv", ".avi", ".mov", ".webm",
|
||||
".mp3", ".flac", ".wav", ".aac",
|
||||
".pdf", ".doc", ".docx", ".ppt", ".pptx",
|
||||
".wasm",
|
||||
}
|
||||
for ext in large_exts:
|
||||
if path.endswith(ext):
|
||||
return True
|
||||
return False
|
||||
|
||||
# ── Plain HTTP forwarding ─────────────────────────────────────
|
||||
|
||||
async def _do_http(self, header_block: bytes, reader, writer):
|
||||
body = b""
|
||||
for raw_line in header_block.split(b"\r\n"):
|
||||
if raw_line.lower().startswith(b"content-length:"):
|
||||
length = int(raw_line.split(b":", 1)[1].strip())
|
||||
body = await reader.readexactly(length)
|
||||
break
|
||||
|
||||
first_line = header_block.split(b"\r\n")[0].decode(errors="replace")
|
||||
log.info("HTTP → %s", first_line)
|
||||
|
||||
if self.mode == "apps_script":
|
||||
# Parse request and relay through Apps Script
|
||||
parts = first_line.strip().split(" ", 2)
|
||||
method = parts[0] if parts else "GET"
|
||||
url = parts[1] if len(parts) > 1 else "/"
|
||||
|
||||
headers = {}
|
||||
for raw_line in header_block.split(b"\r\n")[1:]:
|
||||
if b":" in raw_line:
|
||||
k, v = raw_line.decode(errors="replace").split(":", 1)
|
||||
headers[k.strip()] = v.strip()
|
||||
|
||||
# ── CORS preflight over plain HTTP ────────────────────────────
|
||||
origin = next(
|
||||
(v for k, v in headers.items() if k.lower() == "origin"), ""
|
||||
)
|
||||
acr_method = next(
|
||||
(v for k, v in headers.items()
|
||||
if k.lower() == "access-control-request-method"), ""
|
||||
)
|
||||
acr_headers_val = next(
|
||||
(v for k, v in headers.items()
|
||||
if k.lower() == "access-control-request-headers"), ""
|
||||
)
|
||||
if method.upper() == "OPTIONS" and acr_method:
|
||||
log.debug("CORS preflight (HTTP) → %s (responding locally)", url[:60])
|
||||
writer.write(self._cors_preflight_response(origin, acr_method, acr_headers_val))
|
||||
await writer.drain()
|
||||
return
|
||||
|
||||
# Cache check for GET
|
||||
response = None
|
||||
if method == "GET" and not body:
|
||||
response = self._cache.get(url)
|
||||
if response:
|
||||
log.debug("Cache HIT (HTTP): %s", url[:60])
|
||||
|
||||
if response is None:
|
||||
response = await self._relay_smart(method, url, headers, body)
|
||||
# Cache successful GET
|
||||
if method == "GET" and not body and response:
|
||||
ttl = ResponseCache.parse_ttl(response, url)
|
||||
if ttl > 0:
|
||||
self._cache.put(url, response, ttl)
|
||||
|
||||
# Inject CORS headers for cross-origin requests
|
||||
if origin and response:
|
||||
response = self._inject_cors_headers(response, origin)
|
||||
elif self.mode in ("google_fronting", "custom_domain", "domain_fronting"):
|
||||
# Use WebSocket tunnel for ALL traffic (much faster than forward())
|
||||
response = await self._tunnel_http(header_block, body)
|
||||
else:
|
||||
response = await self.fronter.forward(header_block + body)
|
||||
|
||||
writer.write(response)
|
||||
await writer.drain()
|
||||
|
||||
async def _tunnel_http(self, header_block: bytes, body: bytes) -> bytes:
|
||||
"""Forward plain HTTP via a persistent WebSocket tunnel.
|
||||
|
||||
Instead of opening a new TLS+HTTP connection for each request
|
||||
(the old forward() path), this keeps a WebSocket tunnel open
|
||||
to the target host and pipes raw HTTP through it.
|
||||
Much faster for rapid-fire requests (e.g., Telegram API).
|
||||
"""
|
||||
import re as _re
|
||||
|
||||
# Parse target host:port from the raw HTTP request
|
||||
host = ""
|
||||
port = 80
|
||||
for line in header_block.split(b"\r\n")[1:]:
|
||||
if not line:
|
||||
break
|
||||
if line.lower().startswith(b"host:"):
|
||||
host_val = line.split(b":", 1)[1].strip().decode(errors="replace")
|
||||
if ":" in host_val:
|
||||
h, p = host_val.rsplit(":", 1)
|
||||
try:
|
||||
host, port = h, int(p)
|
||||
except ValueError:
|
||||
host = host_val
|
||||
else:
|
||||
host = host_val
|
||||
break
|
||||
|
||||
if not host:
|
||||
return b"HTTP/1.1 400 Bad Request\r\n\r\nNo Host header\r\n"
|
||||
|
||||
# Rewrite the request line: browser sends absolute URL
|
||||
# (e.g., "GET http://host/path HTTP/1.1") but the target
|
||||
# server expects a relative path ("GET /path HTTP/1.1")
|
||||
first_line = header_block.split(b"\r\n")[0]
|
||||
first_str = first_line.decode(errors="replace")
|
||||
parts = first_str.split(" ", 2)
|
||||
if len(parts) >= 2 and parts[1].startswith("http://"):
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(parts[1])
|
||||
rel_path = parsed.path or "/"
|
||||
if parsed.query:
|
||||
rel_path += "?" + parsed.query
|
||||
new_first = f"{parts[0]} {rel_path}"
|
||||
if len(parts) == 3:
|
||||
new_first += f" {parts[2]}"
|
||||
header_block = new_first.encode() + b"\r\n" + b"\r\n".join(header_block.split(b"\r\n")[1:])
|
||||
|
||||
raw_request = header_block + body
|
||||
|
||||
# Send through tunnel
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
self.fronter.forward(raw_request), timeout=30
|
||||
)
|
||||
except Exception as e:
|
||||
log.error("Tunnel HTTP failed (%s:%d): %s", host, port, e)
|
||||
return b"HTTP/1.1 502 Bad Gateway\r\n\r\nTunnel forward failed\r\n"
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
"""
|
||||
Minimal WebSocket frame encoder / decoder (RFC 6455).
|
||||
|
||||
Only handles binary (opcode 0x02) and close (opcode 0x08) frames.
|
||||
Client-to-server frames are always masked as required by the spec.
|
||||
"""
|
||||
|
||||
import os
|
||||
import struct
|
||||
|
||||
|
||||
def ws_encode(data: bytes, opcode: int = 0x02) -> bytes:
|
||||
"""Encode *data* into a masked binary WebSocket frame."""
|
||||
head = bytearray([0x80 | opcode]) # FIN + opcode
|
||||
|
||||
length = len(data)
|
||||
if length < 126:
|
||||
head.append(0x80 | length)
|
||||
elif length < 0x10000:
|
||||
head.append(0x80 | 126)
|
||||
head += struct.pack("!H", length)
|
||||
else:
|
||||
head.append(0x80 | 127)
|
||||
head += struct.pack("!Q", length)
|
||||
|
||||
mask = os.urandom(4)
|
||||
head += mask
|
||||
|
||||
masked = bytearray(data)
|
||||
for i in range(len(masked)):
|
||||
masked[i] ^= mask[i & 3]
|
||||
|
||||
return bytes(head) + bytes(masked)
|
||||
|
||||
|
||||
def ws_decode(buf: bytes):
|
||||
"""Try to decode one frame from *buf*.
|
||||
|
||||
Returns ``(opcode, payload, consumed_bytes)`` or ``None`` if the
|
||||
buffer does not yet contain a complete frame.
|
||||
"""
|
||||
if len(buf) < 2:
|
||||
return None
|
||||
|
||||
opcode = buf[0] & 0x0F
|
||||
is_masked = buf[1] & 0x80
|
||||
length = buf[1] & 0x7F
|
||||
pos = 2
|
||||
|
||||
if length == 126:
|
||||
if len(buf) < 4:
|
||||
return None
|
||||
length = struct.unpack("!H", buf[2:4])[0]
|
||||
pos = 4
|
||||
elif length == 127:
|
||||
if len(buf) < 10:
|
||||
return None
|
||||
length = struct.unpack("!Q", buf[2:10])[0]
|
||||
pos = 10
|
||||
|
||||
mask = None
|
||||
if is_masked:
|
||||
if len(buf) < pos + 4:
|
||||
return None
|
||||
mask = buf[pos : pos + 4]
|
||||
pos += 4
|
||||
|
||||
if len(buf) < pos + length:
|
||||
return None
|
||||
|
||||
payload = bytearray(buf[pos : pos + length])
|
||||
if mask:
|
||||
for i in range(len(payload)):
|
||||
payload[i] ^= mask[i & 3]
|
||||
|
||||
return opcode, bytes(payload), pos + length
|
||||
@@ -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,10 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DomainFront Tunnel — Bypass DPI censorship via Domain Fronting.
|
||||
DomainFront Tunnel — Bypass DPI censorship via GAS (Google Apps Script) and Cloudflare Workers.
|
||||
|
||||
Run a local HTTP proxy that tunnels all traffic through a CDN using
|
||||
domain fronting: the TLS SNI shows an allowed domain while the encrypted
|
||||
HTTP Host header routes to your Cloudflare Worker relay.
|
||||
Run a local HTTP proxy that tunnels all traffic through a Google Apps
|
||||
Script relay fronted by www.google.com (TLS SNI shows www.google.com
|
||||
while the encrypted Host header points at script.google.com).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -14,26 +14,36 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from core.cert_installer import install_ca, is_ca_trusted
|
||||
from core.mitm import CA_CERT_FILE
|
||||
from core.proxy_server import ProxyServer
|
||||
# Project modules live under ./src — put that folder on sys.path so the
|
||||
# historical flat imports ("from proxy_server import …") keep working.
|
||||
_SRC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "src")
|
||||
if _SRC_DIR not in sys.path:
|
||||
sys.path.insert(0, _SRC_DIR)
|
||||
|
||||
__version__ = "1.0.0"
|
||||
from cert_installer import install_ca, uninstall_ca, is_ca_trusted
|
||||
from constants import __version__
|
||||
from lan_utils import log_lan_access
|
||||
from google_ip_scanner import scan_sync
|
||||
from logging_utils import configure as configure_logging, print_banner
|
||||
from mitm import CA_CERT_FILE
|
||||
from proxy_server import ProxyServer
|
||||
|
||||
|
||||
def setup_logging(level_name: str):
|
||||
level = getattr(logging, level_name.upper(), logging.INFO)
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format="%(asctime)s [%(name)-12s] %(levelname)-7s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
configure_logging(level_name)
|
||||
|
||||
|
||||
_PLACEHOLDER_AUTH_KEYS = {
|
||||
"",
|
||||
"CHANGE_ME_TO_A_STRONG_SECRET",
|
||||
"your-secret-password-here",
|
||||
}
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="domainfront-tunnel",
|
||||
description="Local HTTP proxy that tunnels traffic through domain fronting.",
|
||||
description="Local HTTP proxy that relays traffic through Google Apps Script.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c", "--config",
|
||||
@@ -51,6 +61,17 @@ def parse_args():
|
||||
default=None,
|
||||
help="Override listen host (env: DFT_HOST)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--socks5-port",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Override SOCKS5 listen port (env: DFT_SOCKS5_PORT)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--disable-socks5",
|
||||
action="store_true",
|
||||
help="Disable the built-in SOCKS5 listener.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
||||
@@ -67,16 +88,48 @@ def parse_args():
|
||||
action="store_true",
|
||||
help="Install the MITM CA certificate as a trusted root and exit.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--uninstall-cert",
|
||||
action="store_true",
|
||||
help="Remove the MITM CA certificate from trusted roots and exit.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-cert-check",
|
||||
action="store_true",
|
||||
help="Skip the certificate installation check on startup.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--scan",
|
||||
action="store_true",
|
||||
help="Scan Google IPs to find the fastest reachable one and exit.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
|
||||
# Handle cert-only commands before loading config so they can run standalone.
|
||||
if args.install_cert or args.uninstall_cert:
|
||||
setup_logging("INFO")
|
||||
_log = logging.getLogger("Main")
|
||||
|
||||
if args.install_cert:
|
||||
_log.info("Installing CA certificate…")
|
||||
if not os.path.exists(CA_CERT_FILE):
|
||||
from mitm import MITMCertManager
|
||||
MITMCertManager() # side-effect: creates ca/ca.crt + ca/ca.key
|
||||
ok = install_ca(CA_CERT_FILE)
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
_log.info("Removing CA certificate…")
|
||||
ok = uninstall_ca(CA_CERT_FILE)
|
||||
if ok:
|
||||
_log.info("CA certificate removed successfully.")
|
||||
else:
|
||||
_log.warning("CA certificate removal may have failed. Check logs above.")
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
config_path = args.config
|
||||
|
||||
try:
|
||||
@@ -84,8 +137,31 @@ def main():
|
||||
config = json.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"Config not found: {config_path}")
|
||||
print("Copy config.example.json to config.json and fill in your values.")
|
||||
sys.exit(1)
|
||||
# Offer the interactive wizard if it's available and we're on a TTY.
|
||||
wizard = os.path.join(os.path.dirname(os.path.abspath(__file__)), "setup.py")
|
||||
if os.path.exists(wizard) and sys.stdin.isatty():
|
||||
try:
|
||||
answer = input("Run the interactive setup wizard now? [Y/n]: ").strip().lower()
|
||||
except EOFError:
|
||||
answer = "n"
|
||||
if answer in ("", "y", "yes"):
|
||||
import subprocess
|
||||
rc = subprocess.call([sys.executable, wizard])
|
||||
if rc != 0:
|
||||
sys.exit(rc)
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Could not load config after setup: {e}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("Copy config.example.json to config.json and fill in your values,")
|
||||
print("or run: python setup.py")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("Run: python setup.py (or copy config.example.json to config.json)")
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Invalid JSON in config: {e}")
|
||||
sys.exit(1)
|
||||
@@ -107,6 +183,14 @@ def main():
|
||||
elif os.environ.get("DFT_HOST"):
|
||||
config["listen_host"] = os.environ["DFT_HOST"]
|
||||
|
||||
if args.socks5_port is not None:
|
||||
config["socks5_port"] = args.socks5_port
|
||||
elif os.environ.get("DFT_SOCKS5_PORT"):
|
||||
config["socks5_port"] = int(os.environ["DFT_SOCKS5_PORT"])
|
||||
|
||||
if args.disable_socks5:
|
||||
config["socks5_enabled"] = False
|
||||
|
||||
if args.log_level is not None:
|
||||
config["log_level"] = args.log_level
|
||||
elif os.environ.get("DFT_LOG_LEVEL"):
|
||||
@@ -117,88 +201,120 @@ def main():
|
||||
print(f"Missing required config key: {key}")
|
||||
sys.exit(1)
|
||||
|
||||
mode = config.get("mode", "domain_fronting")
|
||||
if mode == "custom_domain" and "custom_domain" not in config:
|
||||
print("Mode 'custom_domain' requires 'custom_domain' in config")
|
||||
if config.get("auth_key", "") in _PLACEHOLDER_AUTH_KEYS:
|
||||
print(
|
||||
"Refusing to start: 'auth_key' is unset or uses a known placeholder.\n"
|
||||
"Pick a long random secret and set it in both config.json AND "
|
||||
"the AUTH_KEY constant inside Code.gs (they must match)."
|
||||
)
|
||||
sys.exit(1)
|
||||
if mode == "domain_fronting":
|
||||
for key in ("front_domain", "worker_host"):
|
||||
if key not in config:
|
||||
print(f"Mode 'domain_fronting' requires '{key}' in config")
|
||||
sys.exit(1)
|
||||
if mode == "google_fronting":
|
||||
if "worker_host" not in config:
|
||||
print("Mode 'google_fronting' requires 'worker_host' in config (your Cloud Run URL)")
|
||||
sys.exit(1)
|
||||
if mode == "apps_script":
|
||||
sid = config.get("script_ids") or config.get("script_id")
|
||||
if not sid or (isinstance(sid, str) and sid == "YOUR_APPS_SCRIPT_DEPLOYMENT_ID"):
|
||||
print("Mode 'apps_script' requires 'script_id' in config.")
|
||||
print("Deploy the Apps Script from appsscript/Code.gs and paste the Deployment ID.")
|
||||
sys.exit(1)
|
||||
|
||||
# ── Certificate installation ──────────────────────────────────────────
|
||||
if args.install_cert:
|
||||
# Always Apps Script mode — force-set for backward-compat configs.
|
||||
config["mode"] = "apps_script"
|
||||
sid = config.get("script_ids") or config.get("script_id")
|
||||
if not sid or (isinstance(sid, str) and sid == "YOUR_APPS_SCRIPT_DEPLOYMENT_ID"):
|
||||
print("Missing 'script_id' in config.")
|
||||
print("Deploy the Apps Script from Code.gs and paste the Deployment ID.")
|
||||
sys.exit(1)
|
||||
|
||||
# ── Google IP Scanner ──────────────────────────────────────────────────
|
||||
if args.scan:
|
||||
setup_logging("INFO")
|
||||
front_domain = config.get("front_domain", "www.google.com")
|
||||
_log = logging.getLogger("Main")
|
||||
_log.info("Installing CA certificate…")
|
||||
ok = install_ca(CA_CERT_FILE)
|
||||
_log.info(f"Scanning Google IPs (fronting domain: {front_domain})")
|
||||
ok = scan_sync(front_domain)
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
setup_logging(config.get("log_level", "INFO"))
|
||||
log = logging.getLogger("Main")
|
||||
|
||||
mode = config.get("mode", "domain_fronting")
|
||||
log.info("DomainFront Tunnel starting (mode: %s)", mode)
|
||||
print_banner(__version__)
|
||||
log.info("DomainFront Tunnel starting (Apps Script relay)")
|
||||
|
||||
if mode == "custom_domain":
|
||||
log.info("Custom domain : %s", config["custom_domain"])
|
||||
elif mode == "google_fronting":
|
||||
log.info("Google fronting : SNI=%s → Host=%s",
|
||||
config.get("front_domain", "www.google.com"), config["worker_host"])
|
||||
log.info("Google IP : %s", config.get("google_ip", "216.239.38.120"))
|
||||
elif mode == "apps_script":
|
||||
log.info("Apps Script relay : SNI=%s → script.google.com",
|
||||
config.get("front_domain", "www.google.com"))
|
||||
script_ids = config.get("script_ids") or config.get("script_id")
|
||||
if isinstance(script_ids, list):
|
||||
log.info("Script IDs : %d scripts (round-robin)", len(script_ids))
|
||||
for i, sid in enumerate(script_ids):
|
||||
log.info(" [%d] %s", i + 1, sid)
|
||||
else:
|
||||
log.info("Script ID : %s", script_ids)
|
||||
|
||||
# Ensure CA file exists before checking / installing it.
|
||||
# MITMCertManager generates ca/ca.crt on first instantiation.
|
||||
if not os.path.exists(CA_CERT_FILE):
|
||||
from core.mitm import MITMCertManager
|
||||
MITMCertManager() # side-effect: creates ca/ca.crt + ca/ca.key
|
||||
|
||||
# Auto-install MITM CA if not already trusted
|
||||
if not args.no_cert_check:
|
||||
if not is_ca_trusted(CA_CERT_FILE):
|
||||
log.warning("MITM CA is not trusted — attempting automatic installation…")
|
||||
ok = install_ca(CA_CERT_FILE)
|
||||
if ok:
|
||||
log.info("CA certificate installed. You may need to restart your browser.")
|
||||
else:
|
||||
log.error(
|
||||
"Auto-install failed. Run with --install-cert (may need admin/sudo) "
|
||||
"or manually install ca/ca.crt as a trusted root CA."
|
||||
)
|
||||
else:
|
||||
log.info("MITM CA is already trusted.")
|
||||
log.info("Apps Script relay : SNI=%s → script.google.com",
|
||||
config.get("front_domain", "www.google.com"))
|
||||
script_ids = config.get("script_ids") or config.get("script_id")
|
||||
if isinstance(script_ids, list):
|
||||
log.info("Script IDs : %d scripts (sticky per-host)", len(script_ids))
|
||||
for i, sid in enumerate(script_ids):
|
||||
log.info(" [%d] %s", i + 1, sid)
|
||||
else:
|
||||
log.info("Front domain (SNI) : %s", config.get("front_domain", "?"))
|
||||
log.info("Worker host (Host) : %s", config.get("worker_host", "?"))
|
||||
log.info("Script ID : %s", script_ids)
|
||||
|
||||
log.info("Proxy address : %s:%d", config.get("listen_host", "127.0.0.1"), config.get("listen_port", 8080))
|
||||
# Ensure CA file exists before checking / installing it.
|
||||
# MITMCertManager generates ca/ca.crt on first instantiation.
|
||||
if not os.path.exists(CA_CERT_FILE):
|
||||
from mitm import MITMCertManager
|
||||
MITMCertManager() # side-effect: creates ca/ca.crt + ca/ca.key
|
||||
|
||||
# Auto-install MITM CA if not already trusted
|
||||
if not args.no_cert_check:
|
||||
if not is_ca_trusted(CA_CERT_FILE):
|
||||
log.warning("MITM CA is not trusted — attempting automatic installation…")
|
||||
ok = install_ca(CA_CERT_FILE)
|
||||
if ok:
|
||||
log.info("CA certificate installed. You may need to restart your browser.")
|
||||
else:
|
||||
log.error(
|
||||
"Auto-install failed. Run with --install-cert (may need admin/sudo) "
|
||||
"or manually install ca/ca.crt as a trusted root CA."
|
||||
)
|
||||
else:
|
||||
log.info("MITM CA is already trusted.")
|
||||
|
||||
# ── LAN sharing configuration ────────────────────────────────────────
|
||||
lan_sharing = config.get("lan_sharing", False)
|
||||
listen_host = config.get("listen_host", "127.0.0.1")
|
||||
if lan_sharing:
|
||||
# If LAN sharing is enabled and host is still localhost, change to all interfaces
|
||||
if listen_host == "127.0.0.1":
|
||||
config["listen_host"] = "0.0.0.0"
|
||||
listen_host = "0.0.0.0"
|
||||
log.info("LAN sharing enabled — listening on all interfaces")
|
||||
|
||||
# If either explicit LAN sharing is enabled or we bind to all interfaces,
|
||||
# print concrete IPv4 addresses users can use on other devices.
|
||||
lan_mode = lan_sharing or listen_host in ("0.0.0.0", "::")
|
||||
if lan_mode:
|
||||
socks_port = config.get("socks5_port", 1080) if config.get("socks5_enabled", True) else None
|
||||
log_lan_access(config.get("listen_port", 8080), socks_port)
|
||||
|
||||
try:
|
||||
asyncio.run(ProxyServer(config).start())
|
||||
asyncio.run(_run(config))
|
||||
except KeyboardInterrupt:
|
||||
log.info("Stopped")
|
||||
|
||||
|
||||
def _make_exception_handler(log):
|
||||
"""Return an asyncio exception handler that silences Windows WinError 10054
|
||||
noise from connection cleanup (ConnectionResetError in
|
||||
_ProactorBasePipeTransport._call_connection_lost), which is harmless but
|
||||
verbose on Python/Windows when a remote host force-closes a socket."""
|
||||
def handler(loop, context):
|
||||
exc = context.get("exception")
|
||||
cb = context.get("handle") or context.get("source_traceback", "")
|
||||
if (
|
||||
isinstance(exc, ConnectionResetError)
|
||||
and "_call_connection_lost" in str(cb)
|
||||
):
|
||||
return # suppress: benign Windows socket cleanup race
|
||||
log.error("[asyncio] %s", context.get("message", context))
|
||||
if exc:
|
||||
loop.default_exception_handler(context)
|
||||
return handler
|
||||
|
||||
|
||||
async def _run(config):
|
||||
loop = asyncio.get_running_loop()
|
||||
_log = logging.getLogger("asyncio")
|
||||
loop.set_exception_handler(_make_exception_handler(_log))
|
||||
server = ProxyServer(config)
|
||||
try:
|
||||
await server.start()
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
@@ -6,3 +6,15 @@ cryptography>=41.0.0
|
||||
|
||||
# Optional: HTTP/2 multiplexing (faster apps_script relay)
|
||||
h2>=4.1.0
|
||||
|
||||
# CA bundle for TLS verification — skipped on Windows (uses system cert store).
|
||||
certifi>=2024.1.0; sys_platform != "win32"
|
||||
|
||||
# Optional: Brotli decompression (modern websites send `br` encoding)
|
||||
brotli>=1.1.0
|
||||
|
||||
# Optional: Zstandard decompression (some CDNs now serve `zstd`)
|
||||
zstandard>=0.22.0
|
||||
|
||||
# LAN interface detection now uses only the Python standard library
|
||||
# (works on Windows, Linux, macOS, Android/Termux without a C compiler).
|
||||
@@ -4,17 +4,18 @@ cd /d "%~dp0"
|
||||
|
||||
REM -------- MasterHttpRelayVPN one-click launcher (Windows) --------
|
||||
REM Creates a local virtualenv, installs deps, runs the setup wizard
|
||||
REM if needed, then starts the proxy.
|
||||
REM if needed, then starts the proxy. Also checks and installs CA cert
|
||||
REM if not already trusted.
|
||||
|
||||
set "VENV_DIR=.venv"
|
||||
set "PY="
|
||||
|
||||
where py >nul 2>&1
|
||||
if %errorlevel%==0 (
|
||||
if !errorlevel!==0 (
|
||||
set "PY=py -3"
|
||||
) else (
|
||||
where python >nul 2>&1
|
||||
if %errorlevel%==0 (
|
||||
if !errorlevel!==0 (
|
||||
set "PY=python"
|
||||
)
|
||||
)
|
||||
@@ -51,10 +52,29 @@ if errorlevel 1 (
|
||||
)
|
||||
)
|
||||
|
||||
if not exist "config.json" (
|
||||
echo [*] No config.json found — launching setup wizard ...
|
||||
"%VPY%" setup.py
|
||||
if errorlevel 1 (
|
||||
echo [X] Setup cancelled.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
REM -------- Check for uninstall flag --------
|
||||
echo %* | findstr /C:"--uninstall-cert" >nul
|
||||
if not errorlevel 1 (
|
||||
echo [*] Uninstalling CA certificate ...
|
||||
"%VPY%" main.py --uninstall-cert
|
||||
exit /b %errorlevel%
|
||||
)
|
||||
|
||||
|
||||
echo.
|
||||
echo [*] Starting mhr-cfw ...
|
||||
echo.
|
||||
"%VPY%" main.py %*
|
||||
set "RC=%errorlevel%"
|
||||
if not "%RC%"=="0" pause
|
||||
exit /b %RC%
|
||||
exit /b %RC%
|
||||
@@ -41,7 +41,12 @@ if ! "$VPY" -m pip install --disable-pip-version-check -q -r requirements.txt; t
|
||||
"$VPY" -m pip install --disable-pip-version-check -q -r requirements.txt
|
||||
fi
|
||||
|
||||
if [ ! -f "config.json" ]; then
|
||||
echo "[*] No config.json found — launching setup wizard ..."
|
||||
"$VPY" setup.py
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "[*] Starting mhr-cfw ..."
|
||||
echo
|
||||
exec "$VPY" main.py "$@"
|
||||
exec "$VPY" main.py "$@"
|
||||
@@ -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"
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Interactive setup wizard.
|
||||
|
||||
Writes a ready-to-use config.json by prompting only for the values
|
||||
the user really has to choose. Everything else gets a sane default.
|
||||
|
||||
Run:
|
||||
python setup.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import string
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
CONFIG_PATH = HERE / "config.json"
|
||||
EXAMPLE_PATH = HERE / "config.example.json"
|
||||
|
||||
|
||||
def _c(code: str, text: str) -> str:
|
||||
if os.environ.get("NO_COLOR") or not sys.stdout.isatty():
|
||||
return text
|
||||
return f"\033[{code}m{text}\033[0m"
|
||||
|
||||
|
||||
def bold(t: str) -> str: return _c("1", t)
|
||||
def cyan(t: str) -> str: return _c("36", t)
|
||||
def green(t: str) -> str: return _c("32", t)
|
||||
def yellow(t: str) -> str: return _c("33", t)
|
||||
def red(t: str) -> str: return _c("31", t)
|
||||
def dim(t: str) -> str: return _c("2", t)
|
||||
|
||||
|
||||
def prompt(question: str, default: str | None = None) -> str:
|
||||
suffix = f" [{dim(default)}]" if default else ""
|
||||
while True:
|
||||
try:
|
||||
raw = input(f"{cyan('?')} {question}{suffix}: ").strip()
|
||||
except EOFError:
|
||||
print()
|
||||
sys.exit(1)
|
||||
if not raw and default is not None:
|
||||
return default
|
||||
if raw:
|
||||
return raw
|
||||
print(red(" value required"))
|
||||
|
||||
|
||||
def prompt_yes_no(question: str, default: bool = True) -> bool:
|
||||
hint = "Y/n" if default else "y/N"
|
||||
while True:
|
||||
raw = input(f"{cyan('?')} {question} [{hint}]: ").strip().lower()
|
||||
if not raw:
|
||||
return default
|
||||
if raw in ("y", "yes"):
|
||||
return True
|
||||
if raw in ("n", "no"):
|
||||
return False
|
||||
|
||||
|
||||
def random_auth_key(length: int = 32) -> str:
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
def load_base_config() -> dict:
|
||||
if EXAMPLE_PATH.exists():
|
||||
try:
|
||||
with EXAMPLE_PATH.open() as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"mode": "apps_script",
|
||||
"google_ip": "216.239.38.120",
|
||||
"front_domain": "www.google.com",
|
||||
"listen_host": "127.0.0.1",
|
||||
"listen_port": 8085,
|
||||
"socks5_enabled": True,
|
||||
"socks5_port": 1080,
|
||||
"log_level": "INFO",
|
||||
"verify_ssl": True,
|
||||
"lan_sharing": False,
|
||||
"relay_timeout": 25,
|
||||
"tls_connect_timeout": 15,
|
||||
"tcp_connect_timeout": 10,
|
||||
"max_response_body_bytes": 200 * 1024 * 1024,
|
||||
"chunked_download_min_size": 5 * 1024 * 1024,
|
||||
"chunked_download_chunk_size": 512 * 1024,
|
||||
"chunked_download_max_parallel": 8,
|
||||
"chunked_download_max_chunks": 256,
|
||||
"hosts": {},
|
||||
}
|
||||
|
||||
|
||||
def configure_apps_script(cfg: dict) -> dict:
|
||||
print()
|
||||
print(bold("Google Apps Script setup"))
|
||||
print(dim(" 1. Open https://script.google.com -> New project"))
|
||||
print(dim(" 2. Paste apps_script/Code.gs from this repo into the editor"))
|
||||
print(dim(" 3. Set AUTH_KEY in Code.gs to the password below"))
|
||||
print(dim(" 4. Deploy -> New deployment -> Web app"))
|
||||
print(dim(" Execute as: Me | Who has access: Anyone"))
|
||||
print(dim(" 5. Copy the Deployment ID and paste it here"))
|
||||
print()
|
||||
|
||||
ids_raw = prompt(
|
||||
"Deployment ID(s) - comma-separated for load balancing",
|
||||
default=None,
|
||||
)
|
||||
ids = [x.strip() for x in ids_raw.split(",") if x.strip()]
|
||||
if len(ids) == 1:
|
||||
cfg["script_id"] = ids[0]
|
||||
cfg.pop("script_ids", None)
|
||||
else:
|
||||
cfg["script_ids"] = ids
|
||||
cfg.pop("script_id", None)
|
||||
return cfg
|
||||
|
||||
|
||||
def configure_network(cfg: dict) -> dict:
|
||||
print()
|
||||
print(bold("Network settings") + dim(" (press enter to accept defaults)"))
|
||||
cfg["lan_sharing"] = prompt_yes_no(
|
||||
"Enable LAN sharing?",
|
||||
default=bool(cfg.get("lan_sharing", False)),
|
||||
)
|
||||
|
||||
default_host = str(cfg.get("listen_host", "127.0.0.1"))
|
||||
if cfg["lan_sharing"] and default_host == "127.0.0.1":
|
||||
default_host = "0.0.0.0"
|
||||
cfg["listen_host"] = prompt("Listen host", default=default_host)
|
||||
|
||||
port = prompt("HTTP proxy port", default=str(cfg.get("listen_port", 8085)))
|
||||
try:
|
||||
cfg["listen_port"] = int(port)
|
||||
except ValueError:
|
||||
cfg["listen_port"] = 8085
|
||||
|
||||
socks5 = prompt_yes_no("Enable SOCKS5 proxy?", default=bool(cfg.get("socks5_enabled", True)))
|
||||
cfg["socks5_enabled"] = socks5
|
||||
if socks5:
|
||||
sport = prompt("SOCKS5 port", default=str(cfg.get("socks5_port", 1080)))
|
||||
try:
|
||||
cfg["socks5_port"] = int(sport)
|
||||
except ValueError:
|
||||
cfg["socks5_port"] = 1080
|
||||
return cfg
|
||||
|
||||
|
||||
def write_config(cfg: dict) -> None:
|
||||
if CONFIG_PATH.exists():
|
||||
backup = CONFIG_PATH.with_suffix(".json.bak")
|
||||
shutil.copy2(CONFIG_PATH, backup)
|
||||
print(yellow(f" existing config.json backed up to {backup.name}"))
|
||||
with CONFIG_PATH.open("w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print()
|
||||
print(bold("mhr-cfw - setup wizard"))
|
||||
print(dim("Answer a few questions and we'll write config.json for you."))
|
||||
|
||||
if CONFIG_PATH.exists():
|
||||
if not prompt_yes_no("config.json already exists. Overwrite?", default=False):
|
||||
print(dim("Nothing changed."))
|
||||
return 0
|
||||
|
||||
cfg = load_base_config()
|
||||
cfg["mode"] = "apps_script"
|
||||
|
||||
suggested_key = random_auth_key()
|
||||
print()
|
||||
print(bold("Shared password (auth_key)"))
|
||||
print(dim(" Must match AUTH_KEY inside apps_script/Code.gs."))
|
||||
cfg["auth_key"] = prompt("auth_key", default=suggested_key)
|
||||
|
||||
cfg = configure_apps_script(cfg)
|
||||
cfg = configure_network(cfg)
|
||||
|
||||
write_config(cfg)
|
||||
|
||||
print()
|
||||
print(green(f"[OK] wrote {CONFIG_PATH.name}"))
|
||||
print()
|
||||
print(bold("Next step:"))
|
||||
print(f" python main.py")
|
||||
print()
|
||||
print(yellow("Reminder: the AUTH_KEY inside apps_script/Code.gs must match the auth_key"))
|
||||
print(yellow("you just entered - otherwise the relay will return 'unauthorized'."))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
print(dim("Cancelled."))
|
||||
sys.exit(130)
|
||||
@@ -6,7 +6,7 @@ Also attempts to install into Firefox's NSS certificate store when found.
|
||||
|
||||
Usage:
|
||||
from cert_installer import install_ca, is_ca_trusted
|
||||
install_ca("/path/to/ca.crt", cert_name="MHR_CFW")
|
||||
install_ca("/path/to/ca.crt", cert_name="mhr-cfw")
|
||||
"""
|
||||
|
||||
import glob
|
||||
@@ -18,9 +18,9 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
log = logging.getLogger("CertInstaller")
|
||||
log = logging.getLogger("Cert")
|
||||
|
||||
CERT_NAME = "mhr-cfw"
|
||||
CERT_NAME = "MHR-CFW"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -250,28 +250,68 @@ def _install_linux(cert_path: str, cert_name: str) -> bool:
|
||||
return installed
|
||||
|
||||
|
||||
def _is_trusted_linux(cert_path: str) -> bool:
|
||||
"""Check if our cert thumbprint is in the system's OpenSSL trust bundle."""
|
||||
thumbprint = _cert_thumbprint(cert_path)
|
||||
if not thumbprint:
|
||||
def _is_trusted_linux(cert_path: str, cert_name: str = CERT_NAME) -> bool:
|
||||
"""Check whether the cert appears in common Linux trust stores."""
|
||||
try:
|
||||
from cryptography import x509 as _x509
|
||||
from cryptography.hazmat.primitives import hashes as _hashes
|
||||
except Exception:
|
||||
return False
|
||||
bundle_paths = [
|
||||
"/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu
|
||||
"/etc/pki/tls/certs/ca-bundle.crt", # RHEL/Fedora
|
||||
"/etc/ssl/ca-bundle.pem", # OpenSUSE
|
||||
"/etc/ca-certificates/ca-certificates.crt",
|
||||
]
|
||||
# A fast heuristic: check if our CA cert file was copied to known dirs
|
||||
|
||||
try:
|
||||
with open(cert_path, "rb") as f:
|
||||
target_cert = _x509.load_pem_x509_certificate(f.read())
|
||||
target_fp = target_cert.fingerprint(_hashes.SHA1())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# First check the common anchor locations used by the installer.
|
||||
expected_name = f"{cert_name.replace(' ', '_')}.crt"
|
||||
anchor_dirs = [
|
||||
"/usr/local/share/ca-certificates",
|
||||
"/etc/pki/ca-trust/source/anchors",
|
||||
"/etc/ca-certificates/trust-source/anchors",
|
||||
]
|
||||
for d in anchor_dirs:
|
||||
if os.path.isdir(d):
|
||||
for f in os.listdir(d):
|
||||
if "DomainFront" in f or "domainfront" in f.lower():
|
||||
try:
|
||||
if not os.path.isdir(d):
|
||||
continue
|
||||
if expected_name in os.listdir(d):
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Fall back to scanning the system bundle files directly.
|
||||
bundle_paths = [
|
||||
"/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu
|
||||
"/etc/pki/tls/certs/ca-bundle.crt", # RHEL/Fedora
|
||||
"/etc/ssl/ca-bundle.pem", # OpenSUSE
|
||||
"/etc/ca-certificates/ca-certificates.crt",
|
||||
]
|
||||
|
||||
begin = b"-----BEGIN CERTIFICATE-----"
|
||||
end = b"-----END CERTIFICATE-----"
|
||||
for bundle in bundle_paths:
|
||||
try:
|
||||
with open(bundle, "rb") as f:
|
||||
data = f.read()
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for chunk in data.split(begin):
|
||||
if end not in chunk:
|
||||
continue
|
||||
pem = begin + chunk.split(end, 1)[0] + end + b"\n"
|
||||
try:
|
||||
cert = _x509.load_pem_x509_certificate(pem)
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
if cert.fingerprint(_hashes.SHA1()) == target_fp:
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -279,43 +319,225 @@ def _is_trusted_linux(cert_path: str) -> bool:
|
||||
# Firefox NSS (cross-platform)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# def _install_firefox(cert_path: str, cert_name: str):
|
||||
# """Install into all detected Firefox profile NSS databases."""
|
||||
# if not _has_cmd("certutil"):
|
||||
# log.debug("NSS certutil not found — skipping Firefox install.")
|
||||
# return
|
||||
def _install_firefox(cert_path: str, cert_name: str):
|
||||
"""Install into all detected Firefox profile NSS databases."""
|
||||
if not _has_cmd("certutil"):
|
||||
log.debug("NSS certutil not found — skipping Firefox install.")
|
||||
return
|
||||
|
||||
# profile_dirs: list[str] = []
|
||||
# system = platform.system()
|
||||
profile_dirs: list[str] = []
|
||||
system = platform.system()
|
||||
|
||||
# if system == "Windows":
|
||||
# appdata = os.environ.get("APPDATA", "")
|
||||
# profile_dirs += glob.glob(os.path.join(appdata, r"Mozilla\Firefox\Profiles\*"))
|
||||
# elif system == "Darwin":
|
||||
# profile_dirs += glob.glob(os.path.expanduser("~/Library/Application Support/Firefox/Profiles/*"))
|
||||
# else:
|
||||
# profile_dirs += glob.glob(os.path.expanduser("~/.mozilla/firefox/*.default*"))
|
||||
# profile_dirs += glob.glob(os.path.expanduser("~/.mozilla/firefox/*.release*"))
|
||||
if system == "Windows":
|
||||
appdata = os.environ.get("APPDATA", "")
|
||||
profile_dirs += glob.glob(os.path.join(appdata, r"Mozilla\Firefox\Profiles\*"))
|
||||
elif system == "Darwin":
|
||||
profile_dirs += glob.glob(os.path.expanduser("~/Library/Application Support/Firefox/Profiles/*"))
|
||||
else:
|
||||
profile_dirs += glob.glob(os.path.expanduser("~/.mozilla/firefox/*.default*"))
|
||||
profile_dirs += glob.glob(os.path.expanduser("~/.mozilla/firefox/*.release*"))
|
||||
|
||||
# if not profile_dirs:
|
||||
# log.debug("No Firefox profiles found.")
|
||||
# return
|
||||
if not profile_dirs:
|
||||
log.debug("No Firefox profiles found.")
|
||||
return
|
||||
|
||||
# for profile in profile_dirs:
|
||||
# db = f"sql:{profile}" if os.path.exists(os.path.join(profile, "cert9.db")) else f"dbm:{profile}"
|
||||
# try:
|
||||
# # Remove old entry first (ignore errors)
|
||||
# _run(["certutil", "-D", "-n", cert_name, "-d", db], check=False)
|
||||
# _run([
|
||||
# "certutil", "-A",
|
||||
# "-n", cert_name,
|
||||
# "-t", "CT,,",
|
||||
# "-i", cert_path,
|
||||
# "-d", db,
|
||||
# ])
|
||||
# log.info("Installed in Firefox profile: %s", os.path.basename(profile))
|
||||
# except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
||||
# log.warning("Firefox profile %s: %s", os.path.basename(profile), exc)
|
||||
for profile in profile_dirs:
|
||||
db = f"sql:{profile}" if os.path.exists(os.path.join(profile, "cert9.db")) else f"dbm:{profile}"
|
||||
try:
|
||||
# Remove old entry first (ignore errors)
|
||||
_run(["certutil", "-D", "-n", cert_name, "-d", db], check=False)
|
||||
_run([
|
||||
"certutil", "-A",
|
||||
"-n", cert_name,
|
||||
"-t", "CT,,",
|
||||
"-i", cert_path,
|
||||
"-d", db,
|
||||
])
|
||||
log.info("Installed in Firefox profile: %s", os.path.basename(profile))
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
||||
log.warning("Firefox profile %s: %s", os.path.basename(profile), exc)
|
||||
|
||||
|
||||
def _uninstall_firefox(cert_name: str):
|
||||
"""Remove certificate from all detected Firefox profile NSS databases."""
|
||||
if not _has_cmd("certutil"):
|
||||
log.debug("NSS certutil not found — skipping Firefox uninstall.")
|
||||
return
|
||||
|
||||
profile_dirs: list[str] = []
|
||||
system = platform.system()
|
||||
|
||||
if system == "Windows":
|
||||
appdata = os.environ.get("APPDATA", "")
|
||||
profile_dirs += glob.glob(os.path.join(appdata, r"Mozilla\Firefox\Profiles\*"))
|
||||
elif system == "Darwin":
|
||||
profile_dirs += glob.glob(os.path.expanduser("~/Library/Application Support/Firefox/Profiles/*"))
|
||||
else:
|
||||
profile_dirs += glob.glob(os.path.expanduser("~/.mozilla/firefox/*.default*"))
|
||||
profile_dirs += glob.glob(os.path.expanduser("~/.mozilla/firefox/*.release*"))
|
||||
|
||||
if not profile_dirs:
|
||||
log.debug("No Firefox profiles found.")
|
||||
return
|
||||
|
||||
for profile in profile_dirs:
|
||||
db = f"sql:{profile}" if os.path.exists(os.path.join(profile, "cert9.db")) else f"dbm:{profile}"
|
||||
try:
|
||||
result = _run(["certutil", "-D", "-n", cert_name, "-d", db], check=False)
|
||||
if result.returncode == 0:
|
||||
log.info("Removed from Firefox profile: %s", os.path.basename(profile))
|
||||
else:
|
||||
log.debug("Firefox profile %s: certificate not present", os.path.basename(profile))
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
||||
log.debug("Firefox profile %s: %s", os.path.basename(profile), exc)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Uninstall functions
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _uninstall_windows(cert_path: str, cert_name: str) -> bool:
|
||||
"""Remove certificate from the Windows Trusted Root store."""
|
||||
thumbprint = _cert_thumbprint(cert_path)
|
||||
|
||||
# Try per-user store first (no admin required)
|
||||
try:
|
||||
target = thumbprint if thumbprint else cert_name
|
||||
_run(["certutil", "-delstore", "-user", "Root", target])
|
||||
log.info("Certificate removed from Windows user Trusted Root store.")
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
||||
log.warning("certutil user store removal failed: %s", exc)
|
||||
|
||||
# Try system store (requires admin)
|
||||
try:
|
||||
target = thumbprint if thumbprint else cert_name
|
||||
_run(["certutil", "-delstore", "Root", target])
|
||||
log.info("Certificate removed from Windows system Trusted Root store.")
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
||||
log.warning("certutil system store removal failed: %s", exc)
|
||||
|
||||
# Fallback: use PowerShell
|
||||
try:
|
||||
if thumbprint:
|
||||
ps_cmd = (
|
||||
"Get-ChildItem Cert:\\CurrentUser\\Root | "
|
||||
f"Where-Object {{ $_.Thumbprint -eq '{thumbprint}' }} | "
|
||||
"Remove-Item -Force -ErrorAction SilentlyContinue"
|
||||
)
|
||||
else:
|
||||
ps_cmd = (
|
||||
"Get-ChildItem Cert:\\CurrentUser\\Root | "
|
||||
f"Where-Object {{ $_.Subject -like '*CN={cert_name}*' -or $_.FriendlyName -eq '{cert_name}' }} | "
|
||||
"Remove-Item -Force -ErrorAction SilentlyContinue"
|
||||
)
|
||||
_run(["powershell", "-NoProfile", "-Command", ps_cmd])
|
||||
log.info("Certificate removal via PowerShell completed.")
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
||||
log.error("PowerShell removal failed: %s", exc)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _uninstall_macos(cert_name: str) -> bool:
|
||||
"""Remove certificate from the macOS keychains."""
|
||||
login_keychain = os.path.expanduser("~/Library/Keychains/login.keychain-db")
|
||||
if not os.path.exists(login_keychain):
|
||||
login_keychain = os.path.expanduser("~/Library/Keychains/login.keychain")
|
||||
|
||||
try:
|
||||
_run([
|
||||
"security", "delete-certificate",
|
||||
"-c", cert_name,
|
||||
login_keychain,
|
||||
])
|
||||
log.info("Certificate removed from macOS login keychain.")
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
||||
log.warning("login keychain removal failed: %s", exc)
|
||||
|
||||
# Try system keychain (needs sudo)
|
||||
try:
|
||||
_run([
|
||||
"sudo", "security", "delete-certificate",
|
||||
"-c", cert_name,
|
||||
"/Library/Keychains/System.keychain",
|
||||
])
|
||||
log.info("Certificate removed from macOS system keychain.")
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
||||
log.debug("System keychain removal failed: %s", exc)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _uninstall_linux(cert_path: str, cert_name: str) -> bool:
|
||||
"""Remove certificate from Linux trust stores."""
|
||||
distro = _detect_linux_distro()
|
||||
log.info("Detected Linux distro family: %s", distro)
|
||||
|
||||
removed = False
|
||||
|
||||
if distro == "debian":
|
||||
dest_file = f"/usr/local/share/ca-certificates/{cert_name.replace(' ', '_')}.crt"
|
||||
try:
|
||||
if os.path.exists(dest_file):
|
||||
os.remove(dest_file)
|
||||
_run(["update-ca-certificates"])
|
||||
log.info("Certificate removed via update-ca-certificates.")
|
||||
removed = True
|
||||
except (OSError, subprocess.CalledProcessError) as exc:
|
||||
log.warning("Debian removal failed (needs sudo?): %s", exc)
|
||||
try:
|
||||
_run(["sudo", "rm", "-f", dest_file])
|
||||
_run(["sudo", "update-ca-certificates"])
|
||||
log.info("Certificate removed via sudo update-ca-certificates.")
|
||||
removed = True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as exc2:
|
||||
log.warning("sudo Debian removal failed: %s", exc2)
|
||||
|
||||
elif distro == "rhel":
|
||||
dest_file = f"/etc/pki/ca-trust/source/anchors/{cert_name.replace(' ', '_')}.crt"
|
||||
try:
|
||||
if os.path.exists(dest_file):
|
||||
os.remove(dest_file)
|
||||
_run(["update-ca-trust", "extract"])
|
||||
log.info("Certificate removed via update-ca-trust.")
|
||||
removed = True
|
||||
except (OSError, subprocess.CalledProcessError) as exc:
|
||||
log.warning("RHEL removal failed (needs sudo?): %s", exc)
|
||||
try:
|
||||
_run(["sudo", "rm", "-f", dest_file])
|
||||
_run(["sudo", "update-ca-trust", "extract"])
|
||||
log.info("Certificate removed via sudo update-ca-trust.")
|
||||
removed = True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as exc2:
|
||||
log.warning("sudo RHEL removal failed: %s", exc2)
|
||||
|
||||
elif distro == "arch":
|
||||
dest_file = f"/etc/ca-certificates/trust-source/anchors/{cert_name.replace(' ', '_')}.crt"
|
||||
try:
|
||||
if os.path.exists(dest_file):
|
||||
os.remove(dest_file)
|
||||
_run(["trust", "extract-compat"])
|
||||
log.info("Certificate removed via trust extract-compat.")
|
||||
removed = True
|
||||
except (OSError, subprocess.CalledProcessError) as exc:
|
||||
log.warning("Arch removal failed (needs sudo?): %s", exc)
|
||||
try:
|
||||
_run(["sudo", "rm", "-f", dest_file])
|
||||
_run(["sudo", "trust", "extract-compat"])
|
||||
log.info("Certificate removed via sudo trust extract-compat.")
|
||||
removed = True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as exc2:
|
||||
log.warning("sudo Arch removal failed: %s", exc2)
|
||||
|
||||
else:
|
||||
log.warning("Unknown Linux distro. Manually remove %s from trusted CAs.", cert_name)
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -330,7 +552,7 @@ def is_ca_trusted(cert_path: str) -> bool:
|
||||
return _is_trusted_windows(cert_path)
|
||||
if system == "Darwin":
|
||||
return _is_trusted_macos(CERT_NAME)
|
||||
return _is_trusted_linux(cert_path)
|
||||
return _is_trusted_linux(cert_path, CERT_NAME)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -360,6 +582,32 @@ def install_ca(cert_path: str, cert_name: str = CERT_NAME) -> bool:
|
||||
return False
|
||||
|
||||
# Best-effort Firefox install on all platforms
|
||||
# _install_firefox(cert_path, cert_name)
|
||||
_install_firefox(cert_path, cert_name)
|
||||
|
||||
return ok
|
||||
|
||||
|
||||
def uninstall_ca(cert_path: str, cert_name: str = CERT_NAME) -> bool:
|
||||
"""
|
||||
Remove *cert_name* from the system's trusted root CAs on the current platform.
|
||||
Also attempts Firefox NSS removal.
|
||||
|
||||
Returns True if the system store removal succeeded.
|
||||
"""
|
||||
system = platform.system()
|
||||
log.info("Removing CA certificate from %s…", system)
|
||||
|
||||
if system == "Windows":
|
||||
ok = _uninstall_windows(cert_path, cert_name)
|
||||
elif system == "Darwin":
|
||||
ok = _uninstall_macos(cert_name)
|
||||
elif system == "Linux":
|
||||
ok = _uninstall_linux(cert_path, cert_name)
|
||||
else:
|
||||
log.error("Unsupported platform: %s", system)
|
||||
return False
|
||||
|
||||
# Best-effort Firefox uninstall on all platforms
|
||||
_uninstall_firefox(cert_name)
|
||||
|
||||
return ok
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Content-Encoding decoders: gzip (stdlib), brotli (optional), zstd (optional).
|
||||
|
||||
`decode(body, encoding)` returns the decoded bytes, or the original bytes
|
||||
on any error. Use `supported_encodings()` to build an Accept-Encoding value.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import logging
|
||||
import zlib
|
||||
|
||||
log = logging.getLogger("Codec")
|
||||
|
||||
try:
|
||||
import brotli # type: ignore
|
||||
_HAS_BR = True
|
||||
except ImportError: # pragma: no cover
|
||||
brotli = None # type: ignore
|
||||
_HAS_BR = False
|
||||
|
||||
try:
|
||||
import zstandard as _zstd # type: ignore
|
||||
_HAS_ZSTD = True
|
||||
_ZSTD_DCTX = _zstd.ZstdDecompressor()
|
||||
except ImportError: # pragma: no cover
|
||||
_zstd = None # type: ignore
|
||||
_HAS_ZSTD = False
|
||||
_ZSTD_DCTX = None
|
||||
|
||||
|
||||
def supported_encodings() -> str:
|
||||
"""Value for Accept-Encoding that this relay can actually decode."""
|
||||
codecs = ["gzip", "deflate"]
|
||||
if _HAS_BR:
|
||||
codecs.append("br")
|
||||
if _HAS_ZSTD:
|
||||
codecs.append("zstd")
|
||||
return ", ".join(codecs)
|
||||
|
||||
|
||||
def has_brotli() -> bool:
|
||||
return _HAS_BR
|
||||
|
||||
|
||||
def has_zstd() -> bool:
|
||||
return _HAS_ZSTD
|
||||
|
||||
|
||||
def decode(body: bytes, encoding: str) -> bytes:
|
||||
"""Decode *body* according to Content-Encoding.
|
||||
|
||||
Returns the original bytes if the encoding is empty, unknown, or
|
||||
decompression fails (so the caller can safely pass through).
|
||||
"""
|
||||
if not body:
|
||||
return body
|
||||
enc = (encoding or "").strip().lower()
|
||||
if not enc or enc == "identity":
|
||||
return body
|
||||
|
||||
# Multi-coding (rare): "gzip, br" means brotli(gzip(data))
|
||||
if "," in enc:
|
||||
for layer in reversed([s.strip() for s in enc.split(",") if s.strip()]):
|
||||
body = decode(body, layer)
|
||||
return body
|
||||
|
||||
try:
|
||||
if enc == "gzip":
|
||||
return gzip.decompress(body)
|
||||
if enc == "deflate":
|
||||
try:
|
||||
return zlib.decompress(body)
|
||||
except zlib.error:
|
||||
# Some servers send raw deflate without zlib wrapper.
|
||||
return zlib.decompress(body, -zlib.MAX_WBITS)
|
||||
if enc == "br":
|
||||
if not _HAS_BR:
|
||||
log.debug("brotli not installed — body passed through")
|
||||
return body
|
||||
return brotli.decompress(body)
|
||||
if enc == "zstd":
|
||||
if not _HAS_ZSTD:
|
||||
log.debug("zstandard not installed — body passed through")
|
||||
return body
|
||||
return _ZSTD_DCTX.decompress(body)
|
||||
except Exception as exc:
|
||||
log.debug("decompress (%s) failed: %s — returning raw", enc, exc)
|
||||
return body
|
||||
|
||||
return body
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Central location for tunable constants used across the project.
|
||||
|
||||
Values here are chosen for safe defaults; individual entries may be
|
||||
overridden from `config.json` where noted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ── Version ───────────────────────────────────────────────────────────────
|
||||
__version__ = "2.0.1"
|
||||
|
||||
|
||||
# ── Size caps ─────────────────────────────────────────────────────────────
|
||||
MAX_REQUEST_BODY_BYTES = 100 * 1024 * 1024 # 100 MB — inbound browser body
|
||||
MAX_RESPONSE_BODY_BYTES = 200 * 1024 * 1024 # 200 MB — chunked response cap
|
||||
MAX_HEADER_BYTES = 64 * 1024 # 64 KB
|
||||
|
||||
|
||||
# ── Timeouts (seconds) ────────────────────────────────────────────────────
|
||||
CLIENT_IDLE_TIMEOUT = 120
|
||||
RELAY_TIMEOUT = 25
|
||||
TLS_CONNECT_TIMEOUT = 15
|
||||
TCP_CONNECT_TIMEOUT = 10
|
||||
|
||||
# ── Google IP Scanner settings ──────────────────────────────────────────────
|
||||
GOOGLE_SCANNER_TIMEOUT = 4 # Timeout per IP probe (seconds)
|
||||
GOOGLE_SCANNER_CONCURRENCY = 8 # Parallel probes
|
||||
# Candidate Google frontend IPs for scanning (multiple ASNs and regions)
|
||||
CANDIDATE_IPS: tuple[str, ...] = (
|
||||
"216.239.32.120",
|
||||
"216.239.34.120",
|
||||
"216.239.36.120",
|
||||
"216.239.38.120",
|
||||
"142.250.80.142",
|
||||
"142.250.80.138",
|
||||
"142.250.179.110",
|
||||
"142.250.185.110",
|
||||
"142.250.184.206",
|
||||
"142.250.190.238",
|
||||
"142.250.191.78",
|
||||
"172.217.1.206",
|
||||
"172.217.14.206",
|
||||
"172.217.16.142",
|
||||
"172.217.22.174",
|
||||
"172.217.164.110",
|
||||
"172.217.168.206",
|
||||
"172.217.169.206",
|
||||
"34.107.221.82",
|
||||
"142.251.32.110",
|
||||
"142.251.33.110",
|
||||
"142.251.46.206",
|
||||
"142.251.46.238",
|
||||
"142.250.80.170",
|
||||
"142.250.72.206",
|
||||
"142.250.64.206",
|
||||
"142.250.72.110",
|
||||
)
|
||||
|
||||
# ── Response cache ────────────────────────────────────────────────────────
|
||||
CACHE_MAX_MB = 50
|
||||
CACHE_TTL_STATIC_LONG = 3600 # images / fonts
|
||||
CACHE_TTL_STATIC_MED = 1800 # css / js
|
||||
CACHE_TTL_MAX = 86400 # hard cap on any explicit max-age
|
||||
|
||||
|
||||
# ── Connection pool (HTTP/1.1 to Apps Script) ─────────────────────────────
|
||||
POOL_MAX = 50
|
||||
POOL_MIN_IDLE = 15
|
||||
CONN_TTL = 45.0
|
||||
SEMAPHORE_MAX = 50
|
||||
WARM_POOL_COUNT = 30
|
||||
|
||||
|
||||
# ── Batch windows ─────────────────────────────────────────────────────────
|
||||
BATCH_WINDOW_MICRO = 0.005 # 5 ms
|
||||
BATCH_WINDOW_MACRO = 0.050 # 50 ms
|
||||
BATCH_MAX = 50
|
||||
|
||||
|
||||
# ── Fan-out relay (parallel Apps Script instances) ────────────────────────
|
||||
# How long to ignore a script ID after it fails or is unreasonably slow.
|
||||
SCRIPT_BLACKLIST_TTL = 600.0 # 10 minutes
|
||||
|
||||
|
||||
# ── SNI rotation pool ─────────────────────────────────────────────────────
|
||||
# Google-owned SNIs that share the same edge IPs as www.google.com.
|
||||
# When `front_domain` is a Google property, we rotate through this pool on
|
||||
# each new outbound TLS handshake so DPI systems don't see a constant
|
||||
# "always www.google.com" pattern from the client.
|
||||
# Looks like that only mail and google.com not have a shaped DPI, the rest are 16kb shape blocked.
|
||||
# from my own benchmarks . Google and mail have 658 kb ps but the rest have 16 kb ps.
|
||||
FRONT_SNI_POOL_GOOGLE: tuple[str, ...] = (
|
||||
"www.google.com",
|
||||
"mail.google.com",
|
||||
"accounts.google.com",
|
||||
# "drive.google.com",
|
||||
# "docs.google.com",
|
||||
# "calendar.google.com",
|
||||
# "maps.google.com",
|
||||
# "chat.google.com",
|
||||
# "translate.google.com",
|
||||
# "play.google.com",
|
||||
# "lens.google.com",
|
||||
# "scholar.google.com",
|
||||
# "chromewebstore.google.com",
|
||||
)
|
||||
|
||||
|
||||
# ── Per-host stats ────────────────────────────────────────────────────────
|
||||
STATS_LOG_INTERVAL = 300.0 # seconds — how often to log per-host totals
|
||||
STATS_LOG_TOP_N = 10 # how many hosts to include in the log
|
||||
|
||||
|
||||
# ── Direct Google tunnel allow / exclude ──────────────────────────────────
|
||||
# Google web-apps whose real origin must go through the Apps Script relay
|
||||
# because direct SNI tunneling to them does not work reliably behind DPI.
|
||||
GOOGLE_DIRECT_EXACT_EXCLUDE = frozenset({
|
||||
"gemini.google.com",
|
||||
"aistudio.google.com",
|
||||
"notebooklm.google.com",
|
||||
"labs.google.com",
|
||||
"meet.google.com",
|
||||
"accounts.google.com",
|
||||
"ogs.google.com",
|
||||
"mail.google.com",
|
||||
"calendar.google.com",
|
||||
"drive.google.com",
|
||||
"docs.google.com",
|
||||
"chat.google.com",
|
||||
"photos.google.com",
|
||||
"maps.google.com",
|
||||
"myaccount.google.com",
|
||||
"contacts.google.com",
|
||||
"classroom.google.com",
|
||||
"keep.google.com",
|
||||
"play.google.com",
|
||||
"translate.google.com",
|
||||
"assistant.google.com",
|
||||
"lens.google.com",
|
||||
})
|
||||
GOOGLE_DIRECT_SUFFIX_EXCLUDE: tuple[str, ...] = (
|
||||
".meet.google.com",
|
||||
)
|
||||
# Hosts that are known to work better when tunneled directly.
|
||||
GOOGLE_DIRECT_ALLOW_EXACT = frozenset({
|
||||
"www.google.com",
|
||||
"google.com",
|
||||
"safebrowsing.google.com",
|
||||
})
|
||||
GOOGLE_DIRECT_ALLOW_SUFFIXES: tuple[str, ...] = ()
|
||||
|
||||
|
||||
# ── Google-owned domain detection ─────────────────────────────────────────
|
||||
GOOGLE_OWNED_SUFFIXES: tuple[str, ...] = (
|
||||
".google.com", ".google.co",
|
||||
".googleapis.com", ".gstatic.com",
|
||||
".googleusercontent.com",
|
||||
)
|
||||
GOOGLE_OWNED_EXACT = frozenset({
|
||||
"google.com", "gstatic.com", "googleapis.com",
|
||||
})
|
||||
|
||||
|
||||
# ── SNI-rewrite suffixes ──────────────────────────────────────────────────
|
||||
# Google-owned properties whose real SNI is DPI-blocked but are served by
|
||||
# the same edge IP as `front_domain`. Routed through the configured
|
||||
# `google_ip` with SNI rewritten.
|
||||
SNI_REWRITE_SUFFIXES: tuple[str, ...] = (
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
"youtube-nocookie.com",
|
||||
"ytimg.com",
|
||||
"ggpht.com",
|
||||
"gvt1.com",
|
||||
"gvt2.com",
|
||||
"doubleclick.net",
|
||||
"googlesyndication.com",
|
||||
"googleadservices.com",
|
||||
"google-analytics.com",
|
||||
"googletagmanager.com",
|
||||
"googletagservices.com",
|
||||
"fonts.googleapis.com",
|
||||
"script.google.com",
|
||||
)
|
||||
|
||||
|
||||
# ── Response-logging trace hosts ──────────────────────────────────────────
|
||||
TRACE_HOST_SUFFIXES: tuple[str, ...] = (
|
||||
"chatgpt.com",
|
||||
"openai.com",
|
||||
"gemini.google.com",
|
||||
"google.com",
|
||||
"cloudflare.com",
|
||||
"challenges.cloudflare.com",
|
||||
"turnstile",
|
||||
)
|
||||
|
||||
|
||||
# ── File-extension heuristics ─────────────────────────────────────────────
|
||||
STATIC_EXTS: tuple[str, ...] = (
|
||||
".css", ".js", ".mjs", ".woff", ".woff2", ".ttf", ".eot",
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico",
|
||||
".mp3", ".mp4", ".webm", ".wasm", ".avif",
|
||||
)
|
||||
LARGE_FILE_EXTS = frozenset({
|
||||
".bin",
|
||||
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
|
||||
".exe", ".msi", ".dmg", ".deb", ".rpm", ".apk",
|
||||
".iso", ".img",
|
||||
".mp4", ".mkv", ".avi", ".mov", ".webm",
|
||||
".mp3", ".flac", ".wav", ".aac",
|
||||
".pdf", ".doc", ".docx", ".ppt", ".pptx",
|
||||
".wasm",
|
||||
})
|
||||
|
||||
|
||||
# ── Stateful-request hints ────────────────────────────────────────────────
|
||||
STATEFUL_HEADER_NAMES: tuple[str, ...] = (
|
||||
"cookie", "authorization", "proxy-authorization",
|
||||
"origin", "referer", "if-none-match", "if-modified-since",
|
||||
"cache-control", "pragma",
|
||||
)
|
||||
UNCACHEABLE_HEADER_NAMES: tuple[str, ...] = (
|
||||
"cookie", "authorization", "proxy-authorization", "range",
|
||||
"if-none-match", "if-modified-since", "cache-control", "pragma",
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Google IP Scanner — finds the fastest reachable Google frontend IP.
|
||||
|
||||
Scans a list of candidate Google IPs via HTTPS (with SNI fronting), measures
|
||||
latency, and reports results in a formatted table. Useful for finding the best
|
||||
IP to configure in config.json when your current IP is blocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import ssl
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from constants import CANDIDATE_IPS, GOOGLE_SCANNER_TIMEOUT, GOOGLE_SCANNER_CONCURRENCY
|
||||
|
||||
log = logging.getLogger("Scanner")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProbeResult:
|
||||
"""Result of a single IP probe."""
|
||||
ip: str
|
||||
latency_ms: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.latency_ms is not None
|
||||
|
||||
|
||||
async def _probe_ip(
|
||||
ip: str,
|
||||
sni: str,
|
||||
semaphore: asyncio.Semaphore,
|
||||
timeout: float,
|
||||
) -> ProbeResult:
|
||||
"""
|
||||
Probe a single IP via HTTPS with SNI fronting.
|
||||
|
||||
Args:
|
||||
ip: The IP to probe (xxx.xxx.xxx.xxx).
|
||||
sni: The SNI hostname to use in TLS handshake.
|
||||
semaphore: Rate limiter to control concurrency.
|
||||
timeout: Timeout in seconds for the entire probe.
|
||||
|
||||
Returns:
|
||||
ProbeResult with latency_ms (if successful) or error message.
|
||||
"""
|
||||
async with semaphore:
|
||||
start_time = time.time()
|
||||
try:
|
||||
# Create SSL context that skips certificate verification
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
# Connect to IP:443 with SNI set to the fronting domain
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(
|
||||
ip,
|
||||
443,
|
||||
ssl=ctx,
|
||||
server_hostname=sni,
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# Send minimal HTTP HEAD request
|
||||
request = f"HEAD / HTTP/1.1\r\nHost: {sni}\r\nConnection: close\r\n\r\n"
|
||||
writer.write(request.encode())
|
||||
await writer.drain()
|
||||
|
||||
# Read response header (first 256 bytes is plenty for HTTP status)
|
||||
response = await asyncio.wait_for(reader.read(256), timeout=timeout)
|
||||
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check if we got an HTTP response
|
||||
if not response:
|
||||
return ProbeResult(ip=ip, error="empty response")
|
||||
|
||||
response_str = response.decode("utf-8", errors="ignore")
|
||||
if not response_str.startswith("HTTP/"):
|
||||
return ProbeResult(ip=ip, error=f"invalid response: {response_str[:30]!r}")
|
||||
|
||||
# Success — return latency in milliseconds
|
||||
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||
return ProbeResult(ip=ip, latency_ms=elapsed_ms)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return ProbeResult(ip=ip, error="timeout")
|
||||
except ConnectionRefusedError:
|
||||
return ProbeResult(ip=ip, error="connection refused")
|
||||
except ConnectionResetError:
|
||||
return ProbeResult(ip=ip, error="connection reset")
|
||||
except OSError as e:
|
||||
return ProbeResult(ip=ip, error=f"network error: {e.strerror or str(e)}")
|
||||
except Exception as e:
|
||||
return ProbeResult(ip=ip, error=f"probe failed: {type(e).__name__}")
|
||||
|
||||
|
||||
async def run(front_domain: str) -> bool:
|
||||
"""
|
||||
Scan all candidate Google IPs and display results.
|
||||
|
||||
Args:
|
||||
front_domain: The SNI hostname to use (e.g. "www.google.com").
|
||||
|
||||
Returns:
|
||||
True if at least one IP is reachable, False otherwise.
|
||||
"""
|
||||
timeout = GOOGLE_SCANNER_TIMEOUT
|
||||
concurrency = GOOGLE_SCANNER_CONCURRENCY
|
||||
|
||||
print()
|
||||
print(f"Scanning {len(CANDIDATE_IPS)} Google frontend IPs")
|
||||
print(f" SNI: {front_domain}")
|
||||
print(f" Timeout: {timeout}s per IP")
|
||||
print(f" Concurrency: {concurrency} parallel probes")
|
||||
print()
|
||||
|
||||
# Create semaphore to limit concurrency
|
||||
semaphore = asyncio.Semaphore(concurrency)
|
||||
|
||||
# Launch all probes concurrently
|
||||
tasks = [
|
||||
_probe_ip(ip, front_domain, semaphore, timeout)
|
||||
for ip in CANDIDATE_IPS
|
||||
]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
# Sort by latency (successful first, then by speed)
|
||||
results.sort(key=lambda r: (not r.ok, r.latency_ms or float("inf")))
|
||||
|
||||
# Display results table
|
||||
print(f"{'IP':<20} {'LATENCY':<12} {'STATUS':<25}")
|
||||
print(f"{'-' * 20} {'-' * 12} {'-' * 25}")
|
||||
|
||||
ok_count = 0
|
||||
for result in results:
|
||||
if result.ok:
|
||||
print(f"{result.ip:<20} {result.latency_ms:>8}ms OK")
|
||||
ok_count += 1
|
||||
else:
|
||||
status = result.error or "unknown error"
|
||||
print(f"{result.ip:<20} {'—':<12} {status:<25}")
|
||||
|
||||
print()
|
||||
print(f"Result: {ok_count} / {len(results)} reachable")
|
||||
|
||||
if ok_count == 0:
|
||||
print("No Google IPs reachable from this network.")
|
||||
print()
|
||||
return False
|
||||
|
||||
# Show top 3 fastest
|
||||
fastest = [r for r in results if r.ok][:3]
|
||||
print()
|
||||
print("Top 3 fastest IPs:")
|
||||
for i, result in enumerate(fastest, 1):
|
||||
print(f" {i}. {result.ip} ({result.latency_ms}ms)")
|
||||
|
||||
print()
|
||||
print(f"Recommended: Set \"google_ip\": \"{fastest[0].ip}\" in config.json")
|
||||
print()
|
||||
return True
|
||||
|
||||
|
||||
def scan_sync(front_domain: str) -> bool:
|
||||
"""
|
||||
Wrapper to run async scanner from sync context (e.g. main.py).
|
||||
|
||||
Args:
|
||||
front_domain: The SNI hostname to use.
|
||||
|
||||
Returns:
|
||||
True if at least one IP is reachable, False otherwise.
|
||||
"""
|
||||
try:
|
||||
return asyncio.run(run(front_domain))
|
||||
except KeyboardInterrupt:
|
||||
print("\nScan interrupted by user.")
|
||||
return False
|
||||
except Exception as e:
|
||||
log.error(f"Scan failed: {e}")
|
||||
return False
|
||||
@@ -15,12 +15,18 @@ Requires: pip install h2
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import gzip
|
||||
import logging
|
||||
import socket
|
||||
import ssl
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
import certifi
|
||||
except Exception: # optional dependency fallback
|
||||
certifi = None
|
||||
|
||||
import codec
|
||||
|
||||
log = logging.getLogger("H2")
|
||||
|
||||
try:
|
||||
@@ -61,10 +67,15 @@ class H2Transport:
|
||||
"""
|
||||
|
||||
def __init__(self, connect_host: str, sni_host: str,
|
||||
verify_ssl: bool = True):
|
||||
verify_ssl: bool = True,
|
||||
sni_hosts: list[str] | None = None):
|
||||
self.connect_host = connect_host
|
||||
self.sni_host = sni_host
|
||||
self.verify_ssl = verify_ssl
|
||||
# Optional SNI rotation pool — picked round-robin on each new connect.
|
||||
# Falls back to the single sni_host if no pool is given.
|
||||
self._sni_hosts: list[str] = [h for h in (sni_hosts or []) if h] or [sni_host]
|
||||
self._sni_idx: int = 0
|
||||
|
||||
self._reader: asyncio.StreamReader | None = None
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
@@ -74,6 +85,8 @@ class H2Transport:
|
||||
self._write_lock = asyncio.Lock()
|
||||
self._connect_lock = asyncio.Lock()
|
||||
self._read_task: asyncio.Task | None = None
|
||||
self._conn_generation = 0
|
||||
self._last_reconnect_at: float = 0.0
|
||||
|
||||
# Per-stream tracking
|
||||
self._streams: dict[int, _StreamState] = {}
|
||||
@@ -100,12 +113,25 @@ class H2Transport:
|
||||
async def _do_connect(self):
|
||||
"""Establish the HTTP/2 connection with optimized socket settings."""
|
||||
ctx = ssl.create_default_context()
|
||||
# Some Python builds don't expose a usable default CA store.
|
||||
# Load certifi bundle when present to keep TLS verification stable.
|
||||
if certifi is not None:
|
||||
try:
|
||||
ctx.load_verify_locations(cafile=certifi.where())
|
||||
except Exception:
|
||||
pass
|
||||
# Advertise both h2 and http/1.1 — some DPI blocks h2-only ALPN
|
||||
ctx.set_alpn_protocols(["h2", "http/1.1"])
|
||||
if not self.verify_ssl:
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
# Pick next SNI from the rotation pool so repeated reconnects
|
||||
# don't fingerprint as "always www.google.com".
|
||||
sni = self._sni_hosts[self._sni_idx % len(self._sni_hosts)]
|
||||
self._sni_idx += 1
|
||||
self.sni_host = sni # kept for backward-compat logging
|
||||
|
||||
# Create raw TCP socket with TCP_NODELAY BEFORE TLS handshake.
|
||||
# Nagle's algorithm can delay small writes (H2 frames) by up to 200ms
|
||||
# waiting to coalesce — TCP_NODELAY forces immediate send.
|
||||
@@ -115,7 +141,7 @@ class H2Transport:
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.get_event_loop().sock_connect(
|
||||
asyncio.get_running_loop().sock_connect(
|
||||
raw, (self.connect_host, 443)
|
||||
),
|
||||
timeout=15,
|
||||
@@ -123,7 +149,7 @@ class H2Transport:
|
||||
self._reader, self._writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(
|
||||
ssl=ctx,
|
||||
server_hostname=self.sni_host,
|
||||
server_hostname=sni,
|
||||
sock=raw,
|
||||
),
|
||||
timeout=15,
|
||||
@@ -151,35 +177,55 @@ class H2Transport:
|
||||
# Connection-level flow control: ~16MB window
|
||||
self._h2.increment_flow_control_window(2 ** 24 - 65535)
|
||||
|
||||
# Per-stream settings: 1MB initial window, disable server push
|
||||
# Per-stream settings: 8MB initial window (covers all typical relay
|
||||
# request bodies in one shot so we never have to stall for a
|
||||
# WINDOW_UPDATE mid-send). Disable server push.
|
||||
self._h2.update_settings({
|
||||
h2.settings.SettingCodes.INITIAL_WINDOW_SIZE: 1 * 1024 * 1024,
|
||||
h2.settings.SettingCodes.INITIAL_WINDOW_SIZE: 8 * 1024 * 1024,
|
||||
h2.settings.SettingCodes.ENABLE_PUSH: 0,
|
||||
})
|
||||
|
||||
await self._flush()
|
||||
|
||||
self._connected = True
|
||||
self._read_task = asyncio.create_task(self._reader_loop())
|
||||
self._conn_generation += 1
|
||||
generation = self._conn_generation
|
||||
self._read_task = asyncio.create_task(self._reader_loop(generation))
|
||||
log.info("H2 connected → %s (SNI=%s, TCP_NODELAY=on)",
|
||||
self.connect_host, self.sni_host)
|
||||
self.connect_host, sni)
|
||||
|
||||
# Minimum seconds between successive reconnect() calls. Without this,
|
||||
# concurrent relay failures trigger a rapid reconnect storm that causes
|
||||
# repeated "H2 connected → H2 reader loop ended" within milliseconds.
|
||||
_RECONNECT_MIN_INTERVAL = 1.0
|
||||
|
||||
async def reconnect(self):
|
||||
"""Close current connection and re-establish."""
|
||||
await self._close_internal()
|
||||
await self._do_connect()
|
||||
"""Close current connection and re-establish, with backoff."""
|
||||
async with self._connect_lock:
|
||||
loop = asyncio.get_running_loop()
|
||||
elapsed = loop.time() - self._last_reconnect_at
|
||||
if elapsed < self._RECONNECT_MIN_INTERVAL:
|
||||
await asyncio.sleep(self._RECONNECT_MIN_INTERVAL - elapsed)
|
||||
self._last_reconnect_at = loop.time()
|
||||
await self._close_internal()
|
||||
await self._do_connect()
|
||||
|
||||
async def _close_internal(self):
|
||||
self._connected = False
|
||||
if self._read_task:
|
||||
self._read_task.cancel()
|
||||
self._read_task = None
|
||||
read_task = self._read_task
|
||||
self._read_task = None
|
||||
if read_task:
|
||||
read_task.cancel()
|
||||
await asyncio.gather(read_task, return_exceptions=True)
|
||||
if self._writer:
|
||||
try:
|
||||
self._writer.close()
|
||||
writer = self._writer
|
||||
self._writer = None
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
self._writer = None
|
||||
self._reader = None
|
||||
# Wake all pending streams so they can raise
|
||||
for state in self._streams.values():
|
||||
state.error = "Connection closed"
|
||||
@@ -246,7 +292,7 @@ class H2Transport:
|
||||
(":path", path),
|
||||
(":authority", host),
|
||||
(":scheme", "https"),
|
||||
("accept-encoding", "gzip"),
|
||||
("accept-encoding", codec.supported_encodings()),
|
||||
]
|
||||
if headers:
|
||||
for k, v in headers.items():
|
||||
@@ -279,34 +325,41 @@ class H2Transport:
|
||||
if state.error:
|
||||
raise ConnectionError(f"H2 stream error: {state.error}")
|
||||
|
||||
# Auto-decompress gzip
|
||||
# Auto-decompress (gzip / deflate / brotli / zstd)
|
||||
resp_body = bytes(state.data)
|
||||
if state.headers.get("content-encoding", "").lower() == "gzip":
|
||||
try:
|
||||
resp_body = gzip.decompress(resp_body)
|
||||
except Exception:
|
||||
pass
|
||||
enc = state.headers.get("content-encoding", "")
|
||||
if enc:
|
||||
resp_body = codec.decode(resp_body, enc)
|
||||
|
||||
return state.status, state.headers, resp_body
|
||||
|
||||
def _send_body(self, stream_id: int, body: bytes):
|
||||
"""Send request body, respecting H2 flow control window."""
|
||||
# For small bodies (typical JSON payloads), send in one shot
|
||||
"""Send request body, respecting H2 flow control window.
|
||||
|
||||
The initial per-stream window is 8 MB (see _do_connect) which
|
||||
comfortably covers all relay JSON payloads. If the body is ever
|
||||
larger than the available window, we raise rather than silently
|
||||
truncate — the caller will retry on a fresh connection.
|
||||
"""
|
||||
sent = 0
|
||||
total = len(body)
|
||||
while body:
|
||||
max_size = self._h2.local_settings.max_frame_size
|
||||
window = self._h2.local_flow_control_window(stream_id)
|
||||
send_size = min(len(body), max_size, window)
|
||||
if send_size <= 0:
|
||||
# Flow control full — let the reader loop process
|
||||
# window updates before we continue
|
||||
break
|
||||
raise BufferError(
|
||||
f"H2 flow control exhausted after {sent}/{total} bytes; "
|
||||
f"increase initial window or shrink payload"
|
||||
)
|
||||
end = send_size >= len(body)
|
||||
self._h2.send_data(stream_id, body[:send_size], end_stream=end)
|
||||
body = body[send_size:]
|
||||
sent += send_size
|
||||
|
||||
# ── Background reader ─────────────────────────────────────────
|
||||
|
||||
async def _reader_loop(self):
|
||||
async def _reader_loop(self, generation: int):
|
||||
"""Background: read H2 frames, dispatch events to waiting streams."""
|
||||
try:
|
||||
while self._connected:
|
||||
@@ -330,15 +383,36 @@ class H2Transport:
|
||||
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except ssl.SSLError as e:
|
||||
# APPLICATION_DATA_AFTER_CLOSE_NOTIFY is raised when the server
|
||||
# sends data after its TLS close_notify — technically a protocol
|
||||
# violation but very common with CDNs. It just means the
|
||||
# connection is closed; reconnect on the next request.
|
||||
if "APPLICATION_DATA_AFTER_CLOSE_NOTIFY" in str(e):
|
||||
log.debug("H2 TLS session closed by remote (close_notify): %s", e)
|
||||
else:
|
||||
log.error("H2 reader error: %s", e)
|
||||
except Exception as e:
|
||||
log.error("H2 reader error: %s", e)
|
||||
# WinError 121 (semaphore timeout) — Windows OS-level socket
|
||||
# timeout meaning the TCP connection stalled and the OS closed
|
||||
# it. Harmless; treat as a normal drop. On non-Windows
|
||||
# platforms .winerror is absent so getattr returns None.
|
||||
if getattr(e, 'winerror', None) == 121:
|
||||
log.warning("H2 connection dropped (OS socket timeout)")
|
||||
elif "application data after close notify" in str(e).lower():
|
||||
log.debug("H2 reader closed after close_notify: %s", e)
|
||||
else:
|
||||
log.error("H2 reader error: %s", e)
|
||||
finally:
|
||||
self._connected = False
|
||||
for state in self._streams.values():
|
||||
if not state.done.is_set():
|
||||
state.error = "Connection lost"
|
||||
state.done.set()
|
||||
log.info("H2 reader loop ended")
|
||||
if generation != self._conn_generation:
|
||||
log.debug("H2 reader loop ended for stale generation %d", generation)
|
||||
else:
|
||||
self._connected = False
|
||||
for state in self._streams.values():
|
||||
if not state.done.is_set():
|
||||
state.error = "Connection lost"
|
||||
state.done.set()
|
||||
log.info("H2 reader loop ended")
|
||||
|
||||
def _dispatch(self, event):
|
||||
"""Route a single h2 event to its stream."""
|
||||
@@ -416,4 +490,4 @@ class H2Transport:
|
||||
self._h2.ping(b"\x00" * 8)
|
||||
await self._flush()
|
||||
except Exception as e:
|
||||
log.debug("H2 PING failed: %s", e)
|
||||
log.debug("H2 PING failed: %s", e)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
LAN utilities for detecting network interfaces and IPv4 addresses.
|
||||
|
||||
Provides functionality to enumerate local IPv4 addresses for LAN proxy
|
||||
sharing. IPv6 is intentionally not reported — this project only exposes
|
||||
the proxy over IPv4 LANs, which is what every consumer router and
|
||||
phone/desktop client actually uses.
|
||||
|
||||
Implementation notes
|
||||
--------------------
|
||||
This module relies only on the Python standard library so it works
|
||||
out-of-the-box on every supported OS (Windows, Linux, macOS,
|
||||
Android/Termux, *BSD) without requiring a C compiler or native build
|
||||
tools (previous versions depended on ``netifaces``, which needs
|
||||
"Microsoft Visual C++ 14.0 or greater" on Windows and was a frequent
|
||||
install blocker for users on slow connections).
|
||||
|
||||
Strategy (in order):
|
||||
1. "UDP connect trick" to reliably discover the primary outbound
|
||||
IPv4 address on any OS.
|
||||
2. ``socket.getaddrinfo(hostname, AF_INET)`` to enumerate any additional
|
||||
IPv4 addresses bound to the host (covers multi-homed machines).
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
log = logging.getLogger("LAN")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Primary-IP discovery (UDP connect trick)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _primary_ipv4() -> Optional[str]:
|
||||
"""
|
||||
Return the primary local IPv4 the OS would use for outbound traffic.
|
||||
|
||||
Uses a connected UDP socket which does *not* actually send packets —
|
||||
the kernel just picks the source address from its routing table.
|
||||
Works identically on Windows, Linux, macOS, and Android.
|
||||
"""
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.settimeout(0.5)
|
||||
# TEST-NET-1 address, port is arbitrary; no packet is sent for UDP connect().
|
||||
s.connect(('192.0.2.1', 80))
|
||||
return s.getsockname()[0]
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
def get_network_interfaces() -> Dict[str, List[str]]:
|
||||
"""
|
||||
Get network interfaces and their associated non-loopback IPv4 addresses.
|
||||
|
||||
Returns:
|
||||
Dict[str, List[str]]: Interface label -> list of IPv4 addresses.
|
||||
Labels are best-effort synthetic names such as ``"primary"``
|
||||
and ``"host"``.
|
||||
"""
|
||||
interfaces: Dict[str, List[str]] = {}
|
||||
seen_ips: Set[str] = set()
|
||||
|
||||
def _add(label: str, ip: Optional[str]) -> None:
|
||||
if not ip or ip in seen_ips:
|
||||
return
|
||||
if ip.startswith('127.'):
|
||||
return
|
||||
seen_ips.add(ip)
|
||||
interfaces.setdefault(label, []).append(ip)
|
||||
|
||||
# 1) Primary outbound IPv4 (most reliable, cross-platform).
|
||||
_add('primary', _primary_ipv4())
|
||||
|
||||
# 2) Enumerate via hostname resolution (picks up multi-homed hosts).
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
except OSError:
|
||||
hostname = ''
|
||||
|
||||
if hostname:
|
||||
try:
|
||||
for info in socket.getaddrinfo(hostname, None, socket.AF_INET):
|
||||
_add('host', info[4][0])
|
||||
except (socket.gaierror, OSError):
|
||||
pass
|
||||
|
||||
return interfaces
|
||||
|
||||
|
||||
def get_lan_ips(port: int = 8085) -> List[str]:
|
||||
"""
|
||||
Get list of LAN-accessible proxy addresses (IPv4 only).
|
||||
|
||||
Returns a list of IP:port combinations that can be used to access
|
||||
the proxy from other devices on the local network.
|
||||
|
||||
Args:
|
||||
port: The port the proxy is listening on
|
||||
|
||||
Returns:
|
||||
List[str]: List of "IP:port" strings for LAN access
|
||||
"""
|
||||
interfaces = get_network_interfaces()
|
||||
lan_addresses: List[str] = []
|
||||
|
||||
for iface_ips in interfaces.values():
|
||||
for ip in iface_ips:
|
||||
try:
|
||||
addr = ipaddress.IPv4Address(ip)
|
||||
except (ValueError, ipaddress.AddressValueError):
|
||||
continue
|
||||
if addr.is_loopback or addr.is_unspecified:
|
||||
continue
|
||||
if addr.is_private or addr.is_link_local:
|
||||
lan_addresses.append(f"{ip}:{port}")
|
||||
|
||||
# Remove duplicates while preserving order.
|
||||
seen: Set[str] = set()
|
||||
unique_addresses: List[str] = []
|
||||
for addr in lan_addresses:
|
||||
if addr not in seen:
|
||||
seen.add(addr)
|
||||
unique_addresses.append(addr)
|
||||
|
||||
return unique_addresses
|
||||
|
||||
|
||||
def log_lan_access(port: int = 8085, socks_port: Optional[int] = None):
|
||||
"""
|
||||
Log the LAN-accessible proxy addresses for user convenience.
|
||||
|
||||
Args:
|
||||
port: HTTP proxy port
|
||||
socks_port: Optional SOCKS5 proxy port
|
||||
"""
|
||||
lan_http = get_lan_ips(port)
|
||||
if lan_http:
|
||||
log.info("LAN HTTP proxy : %s", ", ".join(lan_http))
|
||||
else:
|
||||
log.warning("No LAN IP addresses detected for HTTP proxy")
|
||||
|
||||
if socks_port:
|
||||
lan_socks = get_lan_ips(socks_port)
|
||||
if lan_socks:
|
||||
log.info("LAN SOCKS5 proxy : %s", ", ".join(lan_socks))
|
||||
else:
|
||||
log.warning("No LAN IP addresses detected for SOCKS5 proxy")
|
||||
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
Pretty, column-aligned, color-aware logging.
|
||||
|
||||
Zero extra dependencies. On Windows, ANSI color support is enabled via
|
||||
the Console API. Colors are disabled automatically when:
|
||||
|
||||
- The output stream is not a TTY (e.g. piped to a file)
|
||||
- The NO_COLOR environment variable is set
|
||||
- DFT_NO_COLOR=1 is set
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
# ─── ANSI palette ──────────────────────────────────────────────────────────
|
||||
|
||||
RESET = "\x1b[0m"
|
||||
BOLD = "\x1b[1m"
|
||||
DIM = "\x1b[2m"
|
||||
ITALIC = "\x1b[3m"
|
||||
|
||||
# 8-bit / truecolor friendly foreground codes
|
||||
FG_GRAY = "\x1b[38;5;245m"
|
||||
FG_BLUE = "\x1b[38;5;39m"
|
||||
FG_CYAN = "\x1b[38;5;45m"
|
||||
FG_GREEN = "\x1b[38;5;42m"
|
||||
FG_YELLOW = "\x1b[38;5;214m"
|
||||
FG_RED = "\x1b[38;5;203m"
|
||||
FG_MAGENTA = "\x1b[38;5;177m"
|
||||
FG_PURPLE = "\x1b[38;5;141m"
|
||||
FG_TEAL = "\x1b[38;5;80m"
|
||||
FG_ORANGE = "\x1b[38;5;208m"
|
||||
|
||||
|
||||
LEVEL_STYLE = {
|
||||
"DEBUG": f"{DIM}{FG_GRAY}",
|
||||
"INFO": f"{FG_GREEN}",
|
||||
"WARNING": f"{BOLD}{FG_YELLOW}",
|
||||
"ERROR": f"{BOLD}{FG_RED}",
|
||||
"CRITICAL": f"{BOLD}{FG_MAGENTA}",
|
||||
}
|
||||
|
||||
LEVEL_GLYPH = {
|
||||
"DEBUG": "·",
|
||||
"INFO": "•",
|
||||
"WARNING": "!",
|
||||
"ERROR": "✕",
|
||||
"CRITICAL": "✕",
|
||||
}
|
||||
|
||||
LEVEL_LABEL = {
|
||||
"DEBUG": "DEBUG",
|
||||
"INFO": "INFO ",
|
||||
"WARNING": "WARN ",
|
||||
"ERROR": "ERROR",
|
||||
"CRITICAL": "CRIT ",
|
||||
}
|
||||
|
||||
# Stable per-component color (keeps log scanning easy).
|
||||
COMPONENT_COLORS = {
|
||||
"Main": FG_CYAN,
|
||||
"Proxy": FG_BLUE,
|
||||
"Fronter": FG_PURPLE,
|
||||
"H2": FG_TEAL,
|
||||
"MITM": FG_ORANGE,
|
||||
"Cert": FG_MAGENTA,
|
||||
}
|
||||
|
||||
|
||||
# ─── color support detection ───────────────────────────────────────────────
|
||||
|
||||
def _supports_color(stream) -> bool:
|
||||
if os.environ.get("NO_COLOR"):
|
||||
return False
|
||||
if os.environ.get("DFT_NO_COLOR") == "1":
|
||||
return False
|
||||
if os.environ.get("FORCE_COLOR") or os.environ.get("DFT_FORCE_COLOR"):
|
||||
return True
|
||||
if not hasattr(stream, "isatty") or not stream.isatty():
|
||||
return False
|
||||
if sys.platform != "win32":
|
||||
return True
|
||||
# Try to enable ANSI on Windows 10+ consoles.
|
||||
try:
|
||||
import ctypes
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
|
||||
# -11 = STD_OUTPUT_HANDLE
|
||||
handle = kernel32.GetStdHandle(-11)
|
||||
mode = ctypes.c_ulong()
|
||||
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
||||
return False
|
||||
if kernel32.SetConsoleMode(
|
||||
handle, mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
||||
):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
# ─── formatter ─────────────────────────────────────────────────────────────
|
||||
|
||||
class PrettyFormatter(logging.Formatter):
|
||||
"""Column-aligned formatter with optional ANSI colors."""
|
||||
|
||||
COMPONENT_WIDTH = 8
|
||||
|
||||
def __init__(self, *, use_color: bool):
|
||||
super().__init__()
|
||||
self.use_color = use_color
|
||||
self._start = time.time()
|
||||
|
||||
# -- helpers ------------------------------------------------------------
|
||||
|
||||
def _c(self, code: str) -> str:
|
||||
return code if self.use_color else ""
|
||||
|
||||
def _fmt_time(self, record: logging.LogRecord) -> str:
|
||||
t = time.localtime(record.created)
|
||||
ms = int((record.created - int(record.created)) * 1000)
|
||||
return f"{time.strftime('%H:%M:%S', t)}"
|
||||
|
||||
def _fmt_level(self, levelname: str) -> str:
|
||||
label = LEVEL_LABEL.get(levelname, levelname[:5].ljust(5))
|
||||
glyph = LEVEL_GLYPH.get(levelname, "·")
|
||||
style = LEVEL_STYLE.get(levelname, "")
|
||||
if self.use_color:
|
||||
return f"{style}{glyph} {label}{RESET}"
|
||||
return f"{glyph} {label}"
|
||||
|
||||
def _fmt_component(self, name: str) -> str:
|
||||
label = name[: self.COMPONENT_WIDTH].ljust(self.COMPONENT_WIDTH)
|
||||
if not self.use_color:
|
||||
return f"[{label}]"
|
||||
color = COMPONENT_COLORS.get(name, FG_GRAY)
|
||||
return f"{DIM}[{RESET}{color}{label}{RESET}{DIM}]{RESET}"
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
# Pre-render message (honors %-args and {}-args).
|
||||
try:
|
||||
message = record.getMessage()
|
||||
except Exception:
|
||||
message = record.msg
|
||||
|
||||
time_part = self._fmt_time(record)
|
||||
level_part = self._fmt_level(record.levelname)
|
||||
comp_part = self._fmt_component(record.name)
|
||||
|
||||
if self.use_color:
|
||||
time_part = f"{DIM}{FG_GRAY}{time_part}{RESET}"
|
||||
|
||||
line = f"{time_part} {level_part} {comp_part} {message}"
|
||||
|
||||
# Exception tracebacks: render dimmed below the main line.
|
||||
if record.exc_info:
|
||||
tb = self.formatException(record.exc_info)
|
||||
if self.use_color:
|
||||
tb = f"{DIM}{FG_GRAY}{tb}{RESET}"
|
||||
line = f"{line}\n{tb}"
|
||||
if record.stack_info:
|
||||
si = record.stack_info
|
||||
if self.use_color:
|
||||
si = f"{DIM}{FG_GRAY}{si}{RESET}"
|
||||
line = f"{line}\n{si}"
|
||||
|
||||
return line
|
||||
|
||||
|
||||
# ─── public API ────────────────────────────────────────────────────────────
|
||||
|
||||
def configure(level: str = "INFO", *, stream=None) -> None:
|
||||
"""Install the pretty formatter on the root logger.
|
||||
|
||||
Safe to call multiple times; replaces prior handlers set up by this
|
||||
module and leaves unrelated handlers alone (for tests / embedding).
|
||||
"""
|
||||
stream = stream or sys.stderr
|
||||
use_color = _supports_color(stream)
|
||||
|
||||
handler = logging.StreamHandler(stream)
|
||||
handler.setFormatter(PrettyFormatter(use_color=use_color))
|
||||
handler.set_name("mhrvpn.pretty")
|
||||
|
||||
root = logging.getLogger()
|
||||
root.setLevel(getattr(logging, level.upper(), logging.INFO))
|
||||
|
||||
# Remove previous pretty handler(s) we installed.
|
||||
for h in list(root.handlers):
|
||||
if getattr(h, "name", "") == "mhrvpn.pretty":
|
||||
root.removeHandler(h)
|
||||
root.addHandler(handler)
|
||||
|
||||
# Suppress cosmetic asyncio warning spam:
|
||||
# "returning true from eof_received() has no effect when using ssl"
|
||||
# It originates in Python's own StreamReaderProtocol when we wrap a
|
||||
# stream in TLS via start_tls(); there's nothing actionable to do.
|
||||
_install_asyncio_noise_filter()
|
||||
|
||||
|
||||
class _AsyncioNoiseFilter(logging.Filter):
|
||||
_SUPPRESSED = (
|
||||
"returning true from eof_received() has no effect when using ssl",
|
||||
)
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool: # noqa: D401
|
||||
try:
|
||||
msg = record.getMessage()
|
||||
except Exception:
|
||||
return True
|
||||
return not any(s in msg for s in self._SUPPRESSED)
|
||||
|
||||
|
||||
def _install_asyncio_noise_filter() -> None:
|
||||
f = _AsyncioNoiseFilter()
|
||||
aio = logging.getLogger("asyncio")
|
||||
# Don't stack duplicates on repeat configure() calls.
|
||||
for existing in list(aio.filters):
|
||||
if isinstance(existing, _AsyncioNoiseFilter):
|
||||
aio.removeFilter(existing)
|
||||
aio.addFilter(f)
|
||||
|
||||
|
||||
def print_banner(version: str, *, stream=None) -> None:
|
||||
"""Print a polished startup banner with color fallbacks."""
|
||||
stream = stream or sys.stderr
|
||||
color = _supports_color(stream)
|
||||
|
||||
def c(code: str) -> str:
|
||||
return code if color else ""
|
||||
|
||||
title = "mhr-cfw"
|
||||
subtitle = "Domain-Fronted GAS-CFW Relay"
|
||||
version_tag = f"v{version}"
|
||||
|
||||
left = f" {title} "
|
||||
center = f" {subtitle} "
|
||||
right = f" {version_tag} "
|
||||
inner_width = max(68, len(left) + len(center) + len(right) + 2)
|
||||
|
||||
gap = inner_width - (len(left) + len(center) + len(right))
|
||||
left_gap = gap // 2
|
||||
right_gap = gap - left_gap
|
||||
|
||||
top = "╭" + ("─" * inner_width) + "╮"
|
||||
mid = "│" + left + (" " * left_gap) + center + (" " * right_gap) + right + "│"
|
||||
bot = "╰" + ("─" * inner_width) + "╯"
|
||||
|
||||
if color:
|
||||
top = f"{DIM}{FG_GRAY}{top}{RESET}"
|
||||
bot = f"{DIM}{FG_GRAY}{bot}{RESET}"
|
||||
mid = (
|
||||
f"{DIM}{FG_GRAY}│{RESET}"
|
||||
f"{BOLD}{FG_CYAN}{left}{RESET}"
|
||||
f"{' ' * left_gap}"
|
||||
f"{FG_GRAY}{center}{RESET}"
|
||||
f"{' ' * right_gap}"
|
||||
f"{BOLD}{FG_TEAL}{right}{RESET}"
|
||||
f"{DIM}{FG_GRAY}│{RESET}"
|
||||
)
|
||||
|
||||
print(top, file=stream)
|
||||
print(mid, file=stream)
|
||||
print(bot, file=stream)
|
||||
stream.flush()
|
||||
@@ -13,6 +13,7 @@ Requires: pip install cryptography
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import tempfile
|
||||
|
||||
@@ -23,11 +24,26 @@ from cryptography.x509.oid import NameOID
|
||||
|
||||
log = logging.getLogger("MITM")
|
||||
|
||||
CA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../cert")
|
||||
# CA lives at the project root (../ca/ relative to this file in src/).
|
||||
# The installed trusted root was generated there; keep using it.
|
||||
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_PROJECT_ROOT = os.path.dirname(_THIS_DIR)
|
||||
CA_DIR = os.path.join(_PROJECT_ROOT, "ca")
|
||||
CA_KEY_FILE = os.path.join(CA_DIR, "ca.key")
|
||||
CA_CERT_FILE = os.path.join(CA_DIR, "ca.crt")
|
||||
|
||||
|
||||
# Filename-safe form of an SNI / hostname. Windows forbids colons,
|
||||
# question marks, etc., so IPv6 literals (and stray Unicode) must be
|
||||
# rewritten before they become part of a cached cert file path.
|
||||
_UNSAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]")
|
||||
|
||||
|
||||
def _safe_domain_filename(domain: str) -> str:
|
||||
cleaned = _UNSAFE_NAME_RE.sub("_", domain.strip(".").lower())
|
||||
return cleaned[:120] or "unknown"
|
||||
|
||||
|
||||
class MITMCertManager:
|
||||
def __init__(self):
|
||||
self._ca_key = None
|
||||
@@ -55,8 +71,8 @@ class MITMCertManager:
|
||||
public_exponent=65537, key_size=2048
|
||||
)
|
||||
subject = issuer = x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, "MHR_CFW"),
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "MHR_CFW"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, "mhr-cfw"),
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "mhr-cfw"),
|
||||
])
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
self._ca_cert = (
|
||||
@@ -95,6 +111,13 @@ class MITMCertManager:
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
# Restrict the CA private key to the current user on POSIX.
|
||||
# os.chmod is a no-op for permission bits on Windows.
|
||||
if os.name == "posix":
|
||||
try:
|
||||
os.chmod(CA_KEY_FILE, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
with open(CA_CERT_FILE, "wb") as f:
|
||||
f.write(self._ca_cert.public_bytes(serialization.Encoding.PEM))
|
||||
|
||||
@@ -105,8 +128,9 @@ class MITMCertManager:
|
||||
if domain not in self._ctx_cache:
|
||||
key_pem, cert_pem = self._generate_domain_cert(domain)
|
||||
|
||||
cert_file = os.path.join(self._cert_dir, f"{domain}.crt")
|
||||
key_file = os.path.join(self._cert_dir, f"{domain}.key")
|
||||
safe = _safe_domain_filename(domain)
|
||||
cert_file = os.path.join(self._cert_dir, f"{safe}.crt")
|
||||
key_file = os.path.join(self._cert_dir, f"{safe}.key")
|
||||
|
||||
ca_pem = self._ca_cert.public_bytes(serialization.Encoding.PEM)
|
||||
with open(cert_file, "wb") as f:
|
||||
@@ -126,8 +150,16 @@ class MITMCertManager:
|
||||
public_exponent=65537, key_size=2048
|
||||
)
|
||||
subject = x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, domain),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, domain[:64] or "unknown"),
|
||||
])
|
||||
|
||||
# SAN: IP literal vs DNS name — x509.DNSName rejects IPv6 literals.
|
||||
import ipaddress as _ipaddress
|
||||
try:
|
||||
san_entry = x509.IPAddress(_ipaddress.ip_address(domain))
|
||||
except ValueError:
|
||||
san_entry = x509.DNSName(domain)
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
@@ -138,7 +170,7 @@ class MITMCertManager:
|
||||
.not_valid_before(now)
|
||||
.not_valid_after(now + datetime.timedelta(days=365))
|
||||
.add_extension(
|
||||
x509.SubjectAlternativeName([x509.DNSName(domain)]),
|
||||
x509.SubjectAlternativeName([san_entry]),
|
||||
critical=False,
|
||||
)
|
||||
.sign(self._ca_key, hashes.SHA256())
|
||||
@@ -150,4 +182,4 @@ class MITMCertManager:
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
cert_pem = cert.public_bytes(serialization.Encoding.PEM)
|
||||
return key_pem, cert_pem
|
||||
return key_pem, cert_pem
|
||||
+1491
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user