mirror of
https://github.com/masterking32/MasterHttpRelayVPN.git
synced 2026-05-17 21:24:37 +03:00
feat: add exit node deployment guide and templates for Val Town, Cloudflare Workers, and Deno Deploy
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
|||||||
|
# Exit Node Deployment Guide (Val Town / Cloudflare / Deno)
|
||||||
|
|
||||||
|
This guide explains how to deploy an exit node for MasterHttpRelayVPN on free platforms.
|
||||||
|
|
||||||
|
Traffic path:
|
||||||
|
|
||||||
|
Browser -> Local Proxy -> Apps Script -> Exit Node -> Target Website
|
||||||
|
|
||||||
|
Use this when destinations block Google datacenter egress.
|
||||||
|
|
||||||
|
## 1) Choose One Provider
|
||||||
|
|
||||||
|
- Val Town
|
||||||
|
- Cloudflare Workers
|
||||||
|
- Deno Deploy
|
||||||
|
|
||||||
|
You only need one provider.
|
||||||
|
|
||||||
|
## 2) Set PSK In Code
|
||||||
|
|
||||||
|
Each template includes:
|
||||||
|
|
||||||
|
const PSK = "CHANGE_ME_TO_A_STRONG_SECRET";
|
||||||
|
|
||||||
|
Replace that value with a long random secret.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
- Use the same PSK in your local config under exit_node.psk.
|
||||||
|
- Never share your deployed URL together with a valid PSK.
|
||||||
|
|
||||||
|
## 3) Deploy On Val Town
|
||||||
|
|
||||||
|
Source file: apps_script/valtown.ts
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Sign in at https://www.val.town
|
||||||
|
2. Create a new Val (TypeScript HTTP endpoint).
|
||||||
|
3. Paste content from apps_script/valtown.ts.
|
||||||
|
4. Set the PSK constant in the code.
|
||||||
|
5. Save and deploy.
|
||||||
|
6. Copy your public URL, usually like https://YOUR-NAME.web.val.run
|
||||||
|
|
||||||
|
## 4) Deploy On Cloudflare Workers
|
||||||
|
|
||||||
|
Source file: apps_script/cloudflare_worker.js
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Sign in at https://dash.cloudflare.com
|
||||||
|
2. Go to Compute -> Workers & Pages.
|
||||||
|
3. Create Application -> Start with Hello World -> Deploy -> Edit Code.
|
||||||
|
4. Replace code with apps_script/cloudflare_worker.js content.
|
||||||
|
5. Set PSK constant in code.
|
||||||
|
6. Deploy.
|
||||||
|
7. Copy URL, usually like https://YOUR-WORKER.YOUR-SUBDOMAIN.workers.dev
|
||||||
|
|
||||||
|
## 5) Deploy On Deno Deploy
|
||||||
|
|
||||||
|
Source file: apps_script/deno_deploy.ts
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Sign in at https://dash.deno.com
|
||||||
|
2. Create new project.
|
||||||
|
3. Upload or paste apps_script/deno_deploy.ts.
|
||||||
|
4. Set PSK constant in code.
|
||||||
|
5. Deploy.
|
||||||
|
6. Copy URL, usually like https://YOUR-PROJECT.deno.dev
|
||||||
|
|
||||||
|
## 6) Configure MasterHttpRelayVPN
|
||||||
|
|
||||||
|
Update config.json:
|
||||||
|
|
||||||
|
{
|
||||||
|
"exit_node": {
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "valtown",
|
||||||
|
"url": "https://YOUR-NAME.web.val.run",
|
||||||
|
"psk": "CHANGE_ME_TO_A_STRONG_SECRET",
|
||||||
|
"mode": "full",
|
||||||
|
"hosts": [
|
||||||
|
"chatgpt.com",
|
||||||
|
"openai.com",
|
||||||
|
"claude.ai",
|
||||||
|
"anthropic.com"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Provider values:
|
||||||
|
- valtown
|
||||||
|
- cloudflare
|
||||||
|
- deno
|
||||||
|
|
||||||
|
If mode is selective, only hosts listed in hosts use the exit node.
|
||||||
|
If mode is full, all relayed traffic uses the exit node.
|
||||||
|
|
||||||
|
## 7) Quick Test
|
||||||
|
|
||||||
|
1. Start app: python main.py
|
||||||
|
2. Ensure proxy is set in browser.
|
||||||
|
3. Open a site known to require non-Google egress.
|
||||||
|
4. If it fails, check:
|
||||||
|
- provider and url are correct
|
||||||
|
- psk matches exactly between config and deployed code
|
||||||
|
- exit_node.enabled is true
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- unauthorized: PSK mismatch
|
||||||
|
- method_not_allowed: endpoint got non-POST request directly (normal when opened in browser)
|
||||||
|
- bad_url: malformed target URL from relay payload
|
||||||
|
- timeout or 5xx: temporary provider issue, redeploy and retry
|
||||||
@@ -178,21 +178,27 @@ Some websites block Google datacenter IPs when traffic exits directly from Apps
|
|||||||
To fix that, configure an exit node so traffic path becomes:
|
To fix that, configure an exit node so traffic path becomes:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Browser -> Local Proxy -> Apps Script -> val.town -> Target website
|
Browser -> Local Proxy -> Apps Script -> Exit Node (Val Town / Cloudflare / Deno) -> Target website
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Open [`apps_script/valtown.ts`](apps_script/valtown.ts) and deploy it on [val.town](https://www.val.town/):
|
You can deploy any one of these free exit-node templates:
|
||||||
- Create a new val
|
|
||||||
- Paste file contents
|
1. Val Town: [`apps_script/valtown.ts`](apps_script/valtown.ts)
|
||||||
- Add HTTP trigger
|
2. Cloudflare Workers: [`apps_script/cloudflare_worker.js`](apps_script/cloudflare_worker.js)
|
||||||
- Copy your generated URL (`https://<name>.web.val.run`)
|
3. Deno Deploy: [`apps_script/deno_deploy.ts`](apps_script/deno_deploy.ts)
|
||||||
2. Set `PSK` inside the val code to a strong secret.
|
|
||||||
3. Add this block to your `config.json`:
|
Full step-by-step deployment guide (all providers):
|
||||||
|
- [EXIT_NODE_DEPLOYMENT.md](EXIT_NODE_DEPLOYMENT.md)
|
||||||
|
|
||||||
|
Set the same PSK secret inside the exit-node code (`PSK` constant) and in `config.json`.
|
||||||
|
|
||||||
|
Then configure provider switching like this:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"exit_node": {
|
"exit_node": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"relay_url": "https://YOUR-NAME.web.val.run",
|
"provider": "valtown",
|
||||||
|
"url": "https://YOUR-NAME.web.val.run",
|
||||||
"psk": "CHANGE_ME_TO_A_STRONG_SECRET",
|
"psk": "CHANGE_ME_TO_A_STRONG_SECRET",
|
||||||
"mode": "full",
|
"mode": "full",
|
||||||
"hosts": [
|
"hosts": [
|
||||||
@@ -205,9 +211,11 @@ Browser -> Local Proxy -> Apps Script -> val.town -> Target website
|
|||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
- For noob setup, only fill `provider`, `url`, and `psk`.
|
||||||
|
- Switch provider by changing `exit_node.provider` and `exit_node.url`.
|
||||||
- `mode: "full"` = everything goes through exit node (ignore `hosts`).
|
- `mode: "full"` = everything goes through exit node (ignore `hosts`).
|
||||||
- `mode: "selective"` = only domains in `hosts` go through exit node.
|
- `mode: "selective"` = only domains in `hosts` go through exit node.
|
||||||
- `psk` must be exactly the same as `PSK` in `valtown.ts`.
|
- `psk` must exactly match your deployed exit node secret.
|
||||||
|
|
||||||
Production recommendation:
|
Production recommendation:
|
||||||
- Keep `verify_ssl: true`
|
- Keep `verify_ssl: true`
|
||||||
@@ -306,7 +314,7 @@ By default, the proxy only listens on `127.0.0.1` (localhost), meaning only your
|
|||||||
|
|
||||||
## Modes Overview
|
## Modes Overview
|
||||||
|
|
||||||
This project focuses entirely on the **Apps Script** relay — a free Google account is all you need, no server, no VPS, no Cloudflare setup. Everything is configured out of the box for this mode.
|
This project is centered on the **Apps Script** relay (free, no VPS needed). For destinations that block Google egress, you can optionally chain a free edge exit node (Val Town, Cloudflare Workers, or Deno Deploy).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -345,6 +353,8 @@ This project focuses entirely on the **Apps Script** relay — a free Google acc
|
|||||||
| `direct_google_exclude` | see [config.example.json](config.example.json) | Google apps that must use the MITM relay path instead of the fast direct tunnel. |
|
| `direct_google_exclude` | see [config.example.json](config.example.json) | Google apps that must use the MITM relay path instead of the fast direct tunnel. |
|
||||||
| `hosts` | `{}` | Manual DNS override: map a hostname to a specific IP. |
|
| `hosts` | `{}` | Manual DNS override: map a hostname to a specific IP. |
|
||||||
| `youtube_via_relay` | `false` | Route YouTube (`youtube.com`, `youtu.be`, `youtube-nocookie.com`) through the Apps Script relay instead of the SNI-rewrite path. The SNI-rewrite path uses Google's frontend IP which enforces SafeSearch and can cause **"Video Unavailable"** errors. Setting this to `true` fixes playback at the cost of using more Apps Script executions and slightly higher latency. |
|
| `youtube_via_relay` | `false` | Route YouTube (`youtube.com`, `youtu.be`, `youtube-nocookie.com`) through the Apps Script relay instead of the SNI-rewrite path. The SNI-rewrite path uses Google's frontend IP which enforces SafeSearch and can cause **"Video Unavailable"** errors. Setting this to `true` fixes playback at the cost of using more Apps Script executions and slightly higher latency. |
|
||||||
|
| `exit_node.provider` | `valtown` | Selected exit-node backend: `valtown`, `cloudflare`, `deno`, or `custom`. |
|
||||||
|
| `exit_node.url` | `""` | Beginner-friendly single URL for the selected provider. |
|
||||||
|
|
||||||
### Optional Dependencies
|
### Optional Dependencies
|
||||||
|
|
||||||
@@ -441,6 +451,52 @@ After scanning, update your `config.json` with the recommended IP and restart th
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## CI/CD Releases (Hidden First Release)
|
||||||
|
|
||||||
|
This repository includes a release workflow at `.github/workflows/release.yml`.
|
||||||
|
|
||||||
|
Default behavior is **hidden for users**:
|
||||||
|
- Tag push (`v*`) creates a GitHub Release as **draft** + **prerelease**
|
||||||
|
- It is not marked as **Latest**
|
||||||
|
|
||||||
|
That means you can run the first release via CI/CD, verify assets, and publish later.
|
||||||
|
|
||||||
|
### Create first hidden release from GitHub Actions
|
||||||
|
|
||||||
|
1. Push a tag:
|
||||||
|
```bash
|
||||||
|
git tag v1.1.0
|
||||||
|
git push origin v1.1.0
|
||||||
|
```
|
||||||
|
2. Wait for the **Release** workflow to finish.
|
||||||
|
3. Open GitHub Releases and review the draft release artifacts.
|
||||||
|
|
||||||
|
### Make it public later
|
||||||
|
|
||||||
|
Run **Actions → Release → Run workflow** with:
|
||||||
|
- `publish = true`
|
||||||
|
- `release_tag = v1.1.0`
|
||||||
|
- `make_public = true`
|
||||||
|
|
||||||
|
This will publish a non-draft, non-prerelease release and mark it as latest.
|
||||||
|
|
||||||
|
### Extra targets (optional, non-blocking)
|
||||||
|
|
||||||
|
`macos-13` runners can be heavily queued, which may leave `macos-x64` waiting for a long time.
|
||||||
|
To keep normal releases fast and reliable, default tag releases now build:
|
||||||
|
- `windows-x64`
|
||||||
|
- `linux-x64`
|
||||||
|
- `macos-arm64`
|
||||||
|
|
||||||
|
If you want extra targets, run **Actions -> Release -> Run workflow** and enable one or more:
|
||||||
|
- `build_macos_x64 = true` (intel macOS)
|
||||||
|
- `build_linux_arm64 = true` (native ARM64 Linux runner)
|
||||||
|
- `build_termux_bundle = true` (Termux source package for arm64/armv7/x86_64 on-device install)
|
||||||
|
|
||||||
|
These extra jobs are optional and non-blocking, so even if one is delayed or unavailable, your main release still completes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -464,7 +520,10 @@ MasterHttpRelayVPN/
|
|||||||
├── config.example.json # Copy to config.json and fill in your values
|
├── config.example.json # Copy to config.json and fill in your values
|
||||||
├── requirements.txt # Python dependencies
|
├── requirements.txt # Python dependencies
|
||||||
├── apps_script/
|
├── apps_script/
|
||||||
│ └── Code.gs # The relay script you deploy to Google Apps Script
|
│ ├── Code.gs # The relay script you deploy to Google Apps Script
|
||||||
|
│ ├── valtown.ts # Exit node template for val.town
|
||||||
|
│ ├── cloudflare_worker.js # Exit node template for Cloudflare Workers
|
||||||
|
│ └── deno_deploy.ts # Exit node template for Deno Deploy
|
||||||
├── ca/ # Generated MITM CA (do NOT share)
|
├── ca/ # Generated MITM CA (do NOT share)
|
||||||
│ ├── ca.crt
|
│ ├── ca.crt
|
||||||
│ └── ca.key
|
│ └── ca.key
|
||||||
|
|||||||
+24
-11
@@ -137,21 +137,27 @@ cp config.example.json config.json
|
|||||||
برای حل این مورد، نود خروجی (exit node) را فعال کنید تا مسیر اینگونه شود:
|
برای حل این مورد، نود خروجی (exit node) را فعال کنید تا مسیر اینگونه شود:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
مرورگر -> پراکسی محلی -> Apps Script -> val.town -> سایت مقصد
|
مرورگر -> پراکسی محلی -> Apps Script -> Exit Node (Val Town / Cloudflare / Deno) -> سایت مقصد
|
||||||
```
|
```
|
||||||
|
|
||||||
1. فایل [apps_script/valtown.ts](apps_script/valtown.ts) را در val.town deploy کنید:
|
میتوانید یکی از این template های رایگان را deploy کنید:
|
||||||
- یک val جدید بسازید
|
|
||||||
- محتوای فایل را paste کنید
|
1. Val Town: [apps_script/valtown.ts](apps_script/valtown.ts)
|
||||||
- HTTP trigger را فعال کنید
|
2. Cloudflare Workers: [apps_script/cloudflare_worker.js](apps_script/cloudflare_worker.js)
|
||||||
- آدرس نهایی (`https://<name>.web.val.run`) را کپی کنید
|
3. Deno Deploy: [apps_script/deno_deploy.ts](apps_script/deno_deploy.ts)
|
||||||
2. مقدار `PSK` داخل فایل val را با یک رمز قوی تغییر دهید.
|
|
||||||
3. در `config.json` این بخش را اضافه/تکمیل کنید:
|
راهنمای کامل مرحلهبهمرحله برای هر provider:
|
||||||
|
- [EXIT_NODE_DEPLOYMENT.md](EXIT_NODE_DEPLOYMENT.md)
|
||||||
|
|
||||||
|
سپس همان secret را هم در کد نود خروجی (`PSK`) و هم در `config.json` یکسان بگذارید.
|
||||||
|
|
||||||
|
نمونه کانفیگ برای سوییچ بین provider ها:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"exit_node": {
|
"exit_node": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"relay_url": "https://YOUR-NAME.web.val.run",
|
"provider": "valtown",
|
||||||
|
"url": "https://YOUR-NAME.web.val.run",
|
||||||
"psk": "CHANGE_ME_TO_A_STRONG_SECRET",
|
"psk": "CHANGE_ME_TO_A_STRONG_SECRET",
|
||||||
"mode": "full",
|
"mode": "full",
|
||||||
"hosts": [
|
"hosts": [
|
||||||
@@ -164,9 +170,11 @@ cp config.example.json config.json
|
|||||||
```
|
```
|
||||||
|
|
||||||
نکات:
|
نکات:
|
||||||
|
- برای تنظیم ساده، فقط `provider`، `url` و `psk` را پر کنید.
|
||||||
|
- برای تغییر backend مقدار `exit_node.provider` و `exit_node.url` را عوض کنید.
|
||||||
- `mode: "full"` یعنی همه ترافیک از exit node عبور میکند (`hosts` نادیده گرفته میشود).
|
- `mode: "full"` یعنی همه ترافیک از exit node عبور میکند (`hosts` نادیده گرفته میشود).
|
||||||
- `mode: "selective"` یعنی فقط دامنههای داخل `hosts` از exit node عبور میکنند.
|
- `mode: "selective"` یعنی فقط دامنههای داخل `hosts` از exit node عبور میکنند.
|
||||||
- مقدار `psk` باید دقیقا با `PSK` در `valtown.ts` یکی باشد.
|
- مقدار `psk` باید دقیقا با secret تنظیمشده در runtime برابر باشد.
|
||||||
|
|
||||||
### مرحله 4: اجرا
|
### مرحله 4: اجرا
|
||||||
|
|
||||||
@@ -284,6 +292,8 @@ json
|
|||||||
| `bypass_hosts` | `["localhost", ".local", ".lan", ".home.arpa"]` | هاستهایی که مستقیم میروند (بدون MITM و بدون رله). برای منابع داخلی شبکه یا سایتهایی که با MITM مشکل دارند. |
|
| `bypass_hosts` | `["localhost", ".local", ".lan", ".home.arpa"]` | هاستهایی که مستقیم میروند (بدون MITM و بدون رله). برای منابع داخلی شبکه یا سایتهایی که با MITM مشکل دارند. |
|
||||||
| `direct_google_exclude` | مراجعه به [config.example.json](config.example.json) | اپهای Google که باید از مسیر MITM برای رله استفاده کنند بهجای tunnel مستقیم. |
|
| `direct_google_exclude` | مراجعه به [config.example.json](config.example.json) | اپهای Google که باید از مسیر MITM برای رله استفاده کنند بهجای tunnel مستقیم. |
|
||||||
| `youtube_via_relay` | `false` | مسیردهی YouTube (`youtube.com`، `youtu.be`، `youtube-nocookie.com`) از طریق رله Apps Script بهجای مسیر SNI-rewrite. مسیر SNI-rewrite از IP فرانتاند Google عبور میکند که SafeSearch را اجباری میکند و میتواند باعث خطای **«ویدیو در دسترس نیست»** شود. با فعال کردن این گزینه، پخش ویدیو درست میشود اما تعداد اجراهای Apps Script بیشتر و تأخیر اندکی بالاتر میرود. |
|
| `youtube_via_relay` | `false` | مسیردهی YouTube (`youtube.com`، `youtu.be`، `youtube-nocookie.com`) از طریق رله Apps Script بهجای مسیر SNI-rewrite. مسیر SNI-rewrite از IP فرانتاند Google عبور میکند که SafeSearch را اجباری میکند و میتواند باعث خطای **«ویدیو در دسترس نیست»** شود. با فعال کردن این گزینه، پخش ویدیو درست میشود اما تعداد اجراهای Apps Script بیشتر و تأخیر اندکی بالاتر میرود. |
|
||||||
|
| `exit_node.provider` | `valtown` | backend انتخابشده برای exit node: `valtown`، `cloudflare`، `deno` یا `custom`. |
|
||||||
|
| `exit_node.url` | `""` | آدرس ساده و اصلی برای provider انتخابشده. |
|
||||||
|
|
||||||
### وابستگیهای اختیاری
|
### وابستگیهای اختیاری
|
||||||
|
|
||||||
@@ -398,7 +408,10 @@ MasterHttpRelayVPN/
|
|||||||
├── config.example.json # نمونه کانفیگ (به config.json کپی شود)
|
├── config.example.json # نمونه کانفیگ (به config.json کپی شود)
|
||||||
├── requirements.txt # وابستگیهای اختیاری پایتون
|
├── requirements.txt # وابستگیهای اختیاری پایتون
|
||||||
├── apps_script/
|
├── apps_script/
|
||||||
│ └── Code.gs # اسکریپت رله روی Google Apps Script
|
│ ├── Code.gs # اسکریپت رله روی Google Apps Script
|
||||||
|
│ ├── valtown.ts # template نود خروجی برای val.town
|
||||||
|
│ ├── cloudflare_worker.js # template نود خروجی برای Cloudflare Workers
|
||||||
|
│ └── deno_deploy.ts # template نود خروجی برای Deno Deploy
|
||||||
├── ca/ # گواهی MITM (هرگز به اشتراک نگذارید)
|
├── ca/ # گواهی MITM (هرگز به اشتراک نگذارید)
|
||||||
│ ├── ca.crt
|
│ ├── ca.crt
|
||||||
│ └── ca.key
|
│ └── ca.key
|
||||||
|
|||||||
@@ -19,23 +19,23 @@ const STRIP_HEADERS = new Set([
|
|||||||
"via",
|
"via",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function decodeBase64ToBytes(input: string): Uint8Array {
|
function decodeBase64ToBytes(input) {
|
||||||
const bin = atob(input);
|
const bin = atob(input);
|
||||||
const out = new Uint8Array(bin.length);
|
const out = new Uint8Array(bin.length);
|
||||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeBytesToBase64(bytes: Uint8Array): string {
|
function encodeBytesToBase64(bytes) {
|
||||||
let bin = "";
|
let bin = "";
|
||||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||||
return btoa(bin);
|
return btoa(bin);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeHeaders(h: unknown): Record<string, string> {
|
function sanitizeHeaders(h) {
|
||||||
const out: Record<string, string> = {};
|
const out = {};
|
||||||
if (!h || typeof h !== "object") return out;
|
if (!h || typeof h !== "object") return out;
|
||||||
for (const [k, v] of Object.entries(h as Record<string, unknown>)) {
|
for (const [k, v] of Object.entries(h)) {
|
||||||
if (!k) continue;
|
if (!k) continue;
|
||||||
if (STRIP_HEADERS.has(k.toLowerCase())) continue;
|
if (STRIP_HEADERS.has(k.toLowerCase())) continue;
|
||||||
out[k] = String(v ?? "");
|
out[k] = String(v ?? "");
|
||||||
@@ -44,7 +44,7 @@ function sanitizeHeaders(h: unknown): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(req: Request): Promise<Response> {
|
async fetch(req) {
|
||||||
try {
|
try {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return Response.json({ e: "method_not_allowed" }, { status: 405 });
|
return Response.json({ e: "method_not_allowed" }, { status: 405 });
|
||||||
@@ -59,28 +59,28 @@ export default {
|
|||||||
return Response.json({ e: "server_psk_missing" }, { status: 500 });
|
return Response.json({ e: "server_psk_missing" }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const k = String((body as any).k ?? "");
|
const k = String(body.k ?? "");
|
||||||
const u = String((body as any).u ?? "");
|
const u = String(body.u ?? "");
|
||||||
const m = String((body as any).m ?? "GET").toUpperCase();
|
const m = String(body.m ?? "GET").toUpperCase();
|
||||||
const h = sanitizeHeaders((body as any).h);
|
const h = sanitizeHeaders(body.h);
|
||||||
const b64 = (body as any).b;
|
const b64 = body.b;
|
||||||
|
|
||||||
if (k !== PSK) return Response.json({ e: "unauthorized" }, { status: 401 });
|
if (k !== PSK) return Response.json({ e: "unauthorized" }, { status: 401 });
|
||||||
if (!/^https?:\/\//i.test(u)) return Response.json({ e: "bad_url" }, { status: 400 });
|
if (!/^https?:\/\//i.test(u)) return Response.json({ e: "bad_url" }, { status: 400 });
|
||||||
|
|
||||||
let payload: Uint8Array | undefined;
|
let payload;
|
||||||
if (typeof b64 === "string" && b64.length > 0) payload = decodeBase64ToBytes(b64);
|
if (typeof b64 === "string" && b64.length > 0) payload = decodeBase64ToBytes(b64);
|
||||||
const requestBody = payload ? Uint8Array.from(payload) : undefined;
|
const requestBody = payload ? Uint8Array.from(payload) : undefined;
|
||||||
|
|
||||||
const resp = await fetch(u, {
|
const resp = await fetch(u, {
|
||||||
method: m,
|
method: m,
|
||||||
headers: h,
|
headers: h,
|
||||||
body: requestBody as unknown as BodyInit,
|
body: requestBody,
|
||||||
redirect: "manual",
|
redirect: "manual",
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = new Uint8Array(await resp.arrayBuffer());
|
const data = new Uint8Array(await resp.arrayBuffer());
|
||||||
const respHeaders: Record<string, string> = {};
|
const respHeaders = {};
|
||||||
resp.headers.forEach((value, key) => {
|
resp.headers.forEach((value, key) => {
|
||||||
respHeaders[key] = value;
|
respHeaders[key] = value;
|
||||||
});
|
});
|
||||||
+7
-3
@@ -87,9 +87,13 @@
|
|||||||
"hosts": {},
|
"hosts": {},
|
||||||
"exit_node": {
|
"exit_node": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"relay_url": "",
|
"provider": "cloudflare",
|
||||||
|
"url": "",
|
||||||
"psk": "",
|
"psk": "",
|
||||||
"mode": "selective",
|
"mode": "full",
|
||||||
"hosts": []
|
"hosts": [
|
||||||
|
"example.com",
|
||||||
|
"example.org"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-9
@@ -214,9 +214,17 @@ class DomainFronter:
|
|||||||
# Useful for sites that block GCP/Apps Script IPs (e.g. ChatGPT).
|
# Useful for sites that block GCP/Apps Script IPs (e.g. ChatGPT).
|
||||||
en_cfg = config.get("exit_node") or {}
|
en_cfg = config.get("exit_node") or {}
|
||||||
self._exit_node_enabled: bool = bool(en_cfg.get("enabled", False))
|
self._exit_node_enabled: bool = bool(en_cfg.get("enabled", False))
|
||||||
self._exit_node_url: str = str(en_cfg.get("relay_url") or "").rstrip("/")
|
self._exit_node_provider: str = self._normalize_exit_node_provider(
|
||||||
|
en_cfg.get("provider"),
|
||||||
|
)
|
||||||
|
self._exit_node_url: str = self._resolve_exit_node_url(
|
||||||
|
self._exit_node_provider,
|
||||||
|
en_cfg,
|
||||||
|
)
|
||||||
self._exit_node_psk: str = str(en_cfg.get("psk") or "")
|
self._exit_node_psk: str = str(en_cfg.get("psk") or "")
|
||||||
self._exit_node_mode: str = str(en_cfg.get("mode") or "selective").lower()
|
self._exit_node_mode: str = str(en_cfg.get("mode") or "selective").lower()
|
||||||
|
if self._exit_node_mode not in ("full", "selective"):
|
||||||
|
self._exit_node_mode = "selective"
|
||||||
self._exit_node_hosts: frozenset[str] = frozenset(
|
self._exit_node_hosts: frozenset[str] = frozenset(
|
||||||
str(h).lower().strip().lstrip(".")
|
str(h).lower().strip().lstrip(".")
|
||||||
for h in (en_cfg.get("hosts") or [])
|
for h in (en_cfg.get("hosts") or [])
|
||||||
@@ -224,8 +232,15 @@ class DomainFronter:
|
|||||||
)
|
)
|
||||||
if self._exit_node_enabled and self._exit_node_url:
|
if self._exit_node_enabled and self._exit_node_url:
|
||||||
log.info(
|
log.info(
|
||||||
"Exit node enabled [mode=%s]: %s",
|
"Exit node enabled [mode=%s, provider=%s]: %s",
|
||||||
self._exit_node_mode, self._exit_node_url,
|
self._exit_node_mode,
|
||||||
|
self._exit_node_provider,
|
||||||
|
self._exit_node_url,
|
||||||
|
)
|
||||||
|
elif self._exit_node_enabled:
|
||||||
|
log.warning(
|
||||||
|
"Exit node is enabled but no URL is configured for provider '%s'",
|
||||||
|
self._exit_node_provider,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Capability log for content encodings.
|
# Capability log for content encodings.
|
||||||
@@ -1107,6 +1122,62 @@ class DomainFronter:
|
|||||||
|
|
||||||
# ── Exit node relay ───────────────────────────────────────────
|
# ── Exit node relay ───────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_exit_node_provider(raw: object) -> str:
|
||||||
|
provider = str(raw or "custom").strip().lower()
|
||||||
|
aliases = {
|
||||||
|
"val": "valtown",
|
||||||
|
"val-town": "valtown",
|
||||||
|
"cloudflare_worker": "cloudflare",
|
||||||
|
"worker": "cloudflare",
|
||||||
|
"cf": "cloudflare",
|
||||||
|
"deno_deploy": "deno",
|
||||||
|
}
|
||||||
|
return aliases.get(provider, provider or "custom")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _resolve_exit_node_url(cls, provider: str,
|
||||||
|
en_cfg: dict[str, object]) -> str:
|
||||||
|
providers = en_cfg.get("providers")
|
||||||
|
if not isinstance(providers, dict):
|
||||||
|
providers = {}
|
||||||
|
|
||||||
|
def _pick_from(mapping: dict[str, object], *keys: str) -> str:
|
||||||
|
for key in keys:
|
||||||
|
value = mapping.get(key)
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.strip()
|
||||||
|
if value:
|
||||||
|
return value.rstrip("/")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Beginner-first: one URL field is enough for all providers.
|
||||||
|
direct = _pick_from(en_cfg, "url")
|
||||||
|
if direct:
|
||||||
|
return direct
|
||||||
|
|
||||||
|
if provider == "valtown":
|
||||||
|
selected = _pick_from(en_cfg, "valtown_url", "val_url") or _pick_from(
|
||||||
|
providers, "valtown", "val_town", "val",
|
||||||
|
)
|
||||||
|
elif provider == "cloudflare":
|
||||||
|
selected = _pick_from(
|
||||||
|
en_cfg, "cloudflare_url", "worker_url", "cf_url",
|
||||||
|
) or _pick_from(
|
||||||
|
providers, "cloudflare", "cloudflare_worker", "worker", "cf",
|
||||||
|
)
|
||||||
|
elif provider == "deno":
|
||||||
|
selected = _pick_from(en_cfg, "deno_url") or _pick_from(
|
||||||
|
providers, "deno", "deno_deploy",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
selected = ""
|
||||||
|
|
||||||
|
if selected:
|
||||||
|
return selected
|
||||||
|
# Backward compatibility for older config format.
|
||||||
|
return _pick_from(en_cfg, "relay_url")
|
||||||
|
|
||||||
def _exit_node_matches(self, url: str) -> bool:
|
def _exit_node_matches(self, url: str) -> bool:
|
||||||
"""Return True if this URL should be routed through the exit node."""
|
"""Return True if this URL should be routed through the exit node."""
|
||||||
if not self._exit_node_enabled or not self._exit_node_url:
|
if not self._exit_node_enabled or not self._exit_node_url:
|
||||||
@@ -1123,22 +1194,22 @@ class DomainFronter:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def _relay_via_exit_node(self, payload: dict) -> bytes:
|
async def _relay_via_exit_node(self, payload: dict) -> bytes:
|
||||||
"""Chain: Apps Script → exit node (val.town) → Destination.
|
"""Chain: Apps Script → edge relay (exit node) → Destination.
|
||||||
|
|
||||||
Traffic path:
|
Traffic path:
|
||||||
Client → [domain fronting TLS] → Apps Script (Google)
|
Client → [domain fronting TLS] → Apps Script (Google)
|
||||||
→ [UrlFetchApp.fetch] → exit node (val.town / non-Google IP)
|
→ [UrlFetchApp.fetch] → exit node (non-Google IP)
|
||||||
→ [fetch()] → Destination
|
→ [fetch()] → Destination
|
||||||
|
|
||||||
This preserves the DPI bypass (Apps Script is always the outbound
|
This preserves the DPI bypass (Apps Script is always the outbound
|
||||||
connection from the client's perspective) while giving the destination
|
connection from the client's perspective) while giving the destination
|
||||||
a non-Google exit IP — fixing Cloudflare Turnstile, ChatGPT, etc.
|
a non-Google exit IP — fixing Cloudflare Turnstile, ChatGPT, etc.
|
||||||
|
|
||||||
The inner payload going to val.town is base64-encoded and sent as the
|
The inner payload going to the exit node is base64-encoded and sent as the
|
||||||
body of the outer Apps Script relay call, so Apps Script POSTs it to
|
body of the outer Apps Script relay call, so Apps Script POSTs it to
|
||||||
the exit node URL on our behalf.
|
the exit node URL on our behalf.
|
||||||
"""
|
"""
|
||||||
# Build inner payload: what val.town will execute
|
# Build inner payload: what the exit node will execute
|
||||||
inner = dict(payload)
|
inner = dict(payload)
|
||||||
inner["k"] = self._exit_node_psk
|
inner["k"] = self._exit_node_psk
|
||||||
inner_json = json.dumps(inner).encode()
|
inner_json = json.dumps(inner).encode()
|
||||||
@@ -1163,9 +1234,9 @@ class DomainFronter:
|
|||||||
# Send through the normal Apps Script relay path (H2 or H1 + retry)
|
# Send through the normal Apps Script relay path (H2 or H1 + retry)
|
||||||
raw = await self._relay_with_retry(outer)
|
raw = await self._relay_with_retry(outer)
|
||||||
|
|
||||||
# raw is now the response from val.town (which is the inner relay JSON)
|
# raw is now the response from the exit node (inner relay JSON)
|
||||||
# _parse_relay_response will decode it into the final HTTP response.
|
# _parse_relay_response will decode it into the final HTTP response.
|
||||||
# But we need to unwrap one level: Apps Script gives us val.town's HTTP
|
# But we need to unwrap one level: Apps Script gives us exit node HTTP
|
||||||
# response body (which is itself a relay JSON), so parse twice.
|
# response body (which is itself a relay JSON), so parse twice.
|
||||||
_, _, apps_script_body = self._split_raw_response(raw)
|
_, _, apps_script_body = self._split_raw_response(raw)
|
||||||
result = self._parse_relay_response(apps_script_body)
|
result = self._parse_relay_response(apps_script_body)
|
||||||
|
|||||||
Reference in New Issue
Block a user