optional upstream forwarder for stable worker exit IP

This commit is contained in:
Mohammad Amin jahani
2026-05-03 12:10:47 +03:00
parent 810f4c8792
commit 40d7c6c23b
4 changed files with 372 additions and 2 deletions
+76
View File
@@ -103,6 +103,82 @@ Open [ipleak.net](https://ipleak.net) in your browser, you should see your ip ad
<img width="1454" height="869" alt="image" src="https://github.com/user-attachments/assets/dfd3316d-69b6-4b0e-b564-fdb055dbdafd" />
---
## 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 [`script/upstream_forwarder.js`](script/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 script/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.
---
## Sources for this project
+71
View File
@@ -29,6 +29,7 @@
- [مرحله ۵ — اجرا](#مرحله-۵--اجرا)
- [مرحله ۶ — تنظیم مرورگر](#مرحله-۶--تنظیم-مرورگر)
- [مرحله ۷ — تست اتصال](#مرحله-۷--تست-اتصال)
- [اختیاری — IP خروجی پایدار با Upstream Forwarder](#اختیاری--ip-خروجی-پایدار-با-upstream-forwarder)
- [تنظیمات پیشرفته config.json](#تنظیمات-پیشرفته-configjson)
- [ابزار اسکن IP گوگل](#ابزار-اسکن-ip-گوگل)
- [اشتراک‌گذاری در شبکه محلی (LAN)](#اشتراکگذاری-در-شبکه-محلی-lan)
@@ -450,6 +451,76 @@ Port : 1080
**۳.** تست دسترسی به سایت فیلترشده: هر سایتی که قبلاً در دسترس نبود را امتحان کنید.
---
## اختیاری — IP خروجی پایدار با Upstream Forwarder
سایت‌هایی که از CAPTCHA استفاده می‌کنند (Cloudflare Turnstile، reCAPTCHA، hCaptcha) توکن حل‌شده را به IP بازکننده‌ی چالش گره می‌زنند. Cloudflare Worker در هر درخواست از IP متفاوتی خروج می‌گیرد، بنابراین حتی پس از حل CAPTCHA، تأیید سمت سرور رد می‌شود. این افزونه‌ی اختیاری به Worker اجازه می‌دهد همه‌ی `fetch()` ها را از طریق یک سرور Node کوچک روی VPS شما (با IP ثابت) عبور دهد — به‌طوری که سایت مقصد همیشه یک IP خروجی ثابت ببیند.
### چه زمانی به این نیاز دارید
- سایت‌های پشت Cloudflare bot challenge شما را به‌صورت حلقه‌ای به صفحه‌ی چالش برمی‌گردانند.
- فرم لاگین بعد از حل reCAPTCHA/hCaptcha رد می‌شود.
- نیاز به پایداری کوکی بین درخواست‌ها دارید (مثل `cf_clearance`).
اگر این مشکلات را ندارید، آن را تنظیم نکنید — Worker دقیقاً مثل قبل کار می‌کند.
### چرا به سرور جداگانه نیاز است
Cloudflare Worker آی‌پی خروجی ثابتی ندارد — هر `fetch()` از یک IP در شبکه‌ی edge کلودفلر خارج می‌شود که دائماً تغییر می‌کند، و دقیقاً همین چیزی است که توکن‌های CAPTCHA وابسته به IP را می‌شکند. گزینه‌های static egress خود کلودفلر (BYOIP، Egress Workers) فقط در پلن Enterprise در دسترس‌اند، بنابراین یک VPS کوچک با IP ثابت ساده‌ترین راه‌حل عملی است. forwarder فقط یک پراکسی نازک است که `fetch()` را از یک آدرس ثابت بازارسال می‌کند.
### ۱. اجرای forwarder روی VPS
پیاده‌سازی مرجع در فایل [`script/upstream_forwarder.js`](script/upstream_forwarder.js) قرار دارد. به Node نسخه ۱۸+ نیاز دارد و هیچ وابستگی خارجی ندارد. آن را پشت Caddy یا nginx با TLS اجرا کنید — Worker آدرس‌های غیر HTTPS را نمی‌پذیرد.
```bash
# روی VPS (مثال Ubuntu/Debian):
sudo apt install -y nodejs # باید نسخه ۱۸ یا بالاتر باشد
export AUTH_KEY="یک-کلید-تصادفی-حداقل-۳۲-کاراکتر"
export PORT=8787
node script/upstream_forwarder.js
```
تنظیم Caddy برای TLS خودکار:
```
forwarder.example.com {
reverse_proxy 127.0.0.1:8787
}
```
تست سریع:
```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":{}}'
```
پاسخ دیکد‌شده باید **IP خود VPS** را نشان دهد.
### ۲. اتصال Worker به forwarder
در Cloudflare dashboard → Worker شما → **Settings → Variables and Secrets**:
| نام | نوع | مقدار |
|-----|-----|-------|
| `UPSTREAM_FORWARDER_URL` | Secret | `https://forwarder.example.com/fwd` |
| `UPSTREAM_AUTH_KEY` | Secret | همان `AUTH_KEY` که روی VPS گذاشتید |
| `UPSTREAM_FAIL_MODE` | Variable | پیش‌فرض `closed` — در صورت خطای forwarder کد ۵۰۲ بازمی‌گرداند. مقدار `open` باعث می‌شود به fetch مستقیم برگردد. |
| `UPSTREAM_TIMEOUT_MS` | Variable (اختیاری) | پیش‌فرض `25000` |
ذخیره و Worker را دوباره Deploy کنید.
### ۳. تست
از طریق پروکسی به `https://httpbin.org/ip` بروید — باید **IP VPS** را ببینید، نه Cloudflare. سپس سایتی که قبلاً CAPTCHA آن کار نمی‌کرد را امتحان کنید — چالش باید این بار به‌درستی تأیید شود.
> forwarder بدون `AUTH_KEY` راه‌اندازی نمی‌شود. هر کسی که آدرس و کلید را داشته باشد می‌تواند از آن به‌عنوان رله استفاده کند، بنابراین هر دو را محرمانه نگه دارید.
---
## تنظیمات پیشرفته config.json
فایل `config.json` گزینه‌های زیادی دارد که می‌توانید برحسب نیاز تنظیم کنید:
+143
View File
@@ -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/script/worker.js:
// POST /fwd body: { u, m, h, b, ct, r } → { s, h, b } or { e }
"use strict";
const http = require("http");
const AUTH_KEY = process.env.AUTH_KEY || "";
const PORT = parseInt(process.env.PORT, 10) || 8787;
const HOST = process.env.HOST || "127.0.0.1";
if (!AUTH_KEY || AUTH_KEY.length < 32) {
console.error("FATAL: AUTH_KEY env var missing or shorter than 32 chars.");
process.exit(1);
}
// Mirrors SKIP_HEADERS in main/script/Code.gs:6-9.
const SKIP_HEADERS = new Set([
"host",
"connection",
"content-length",
"transfer-encoding",
"proxy-connection",
"proxy-authorization"
]);
const STATUS_PAGE =
"<!DOCTYPE html><html><head><title>Forwarder Active</title></head>" +
'<body style="font-family:sans-serif;max-width:600px;margin:40px auto">' +
'<h1>Forwarder <span style="color:#16a34a;font-weight:700">Active</span></h1>' +
"<p>Upstream forwarder for the relay Worker.</p>" +
"</body></html>";
const server = http.createServer(async (req, res) => {
try {
if (req.method === "GET" && (req.url === "/" || req.url === "")) {
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
res.end(STATUS_PAGE);
return;
}
if (req.method !== "POST" || req.url !== "/fwd") {
sendJson(res, 404, { e: "not found" });
return;
}
if (req.headers["x-upstream-auth"] !== AUTH_KEY) {
sendJson(res, 401, { e: "unauthorized" });
return;
}
const raw = await readBody(req);
let body;
try {
body = JSON.parse(raw);
} catch (_) {
sendJson(res, 400, { e: "invalid json" });
return;
}
if (!body.u || typeof body.u !== "string" || !/^https?:\/\//i.test(body.u)) {
sendJson(res, 400, { e: "bad url" });
return;
}
const headers = {};
if (body.h && typeof body.h === "object") {
for (const [k, v] of Object.entries(body.h)) {
if (typeof v !== "string") continue;
if (SKIP_HEADERS.has(k.toLowerCase())) continue;
headers[k] = v;
}
}
headers["x-fwd-hop"] = "1";
const fetchOptions = {
method: (body.m || "GET").toUpperCase(),
headers,
redirect: body.r === false ? "manual" : "follow"
};
if (body.b) {
fetchOptions.body = Buffer.from(body.b, "base64");
}
let resp;
try {
resp = await fetch(body.u, fetchOptions);
} catch (err) {
sendJson(res, 502, { e: "fetch failed: " + String(err && err.message || err) });
return;
}
const buf = Buffer.from(await resp.arrayBuffer());
const responseHeaders = {};
resp.headers.forEach((v, k) => {
responseHeaders[k] = v;
});
sendJson(res, 200, {
s: resp.status,
h: responseHeaders,
b: buf.toString("base64")
});
} catch (err) {
sendJson(res, 500, { e: String(err && err.message || err) });
}
});
server.listen(PORT, HOST, () => {
console.log("upstream_forwarder listening on " + HOST + ":" + PORT);
});
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on("data", c => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
function sendJson(res, status, obj) {
const body = JSON.stringify(obj);
res.writeHead(status, { "content-type": "application/json" });
res.end(body);
}
+82 -2
View File
@@ -2,10 +2,14 @@
const WORKER_URL = "myworker.workers.dev";
const DEFAULT_UPSTREAM_TIMEOUT_MS = 25000;
export default {
async fetch(request) {
async fetch(request, env) {
try {
if (request.headers.get("x-relay-hop") === "1") {
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);
}
@@ -25,6 +29,13 @@ export default {
return json({ e: "self-fetch blocked" }, 400);
}
const upstreamUrl = (env && env.UPSTREAM_FORWARDER_URL) || "";
if (upstreamUrl) {
const upstreamResp = await forwardViaUpstream(req, env, upstreamUrl);
if (upstreamResp) return upstreamResp;
// fall through to direct fetch only when fail-mode is open
}
const headers = new Headers();
if (req.h && typeof req.h === "object") {
for (const [k, v] of Object.entries(req.h)) {
@@ -80,6 +91,75 @@ export default {
}
};
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,