mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-18 08:34:35 +03:00
4aac9a793f
Two changes addressing user-reported issues today:
1. Exit-node feature ported from upstream masterking32@464a6e1d, with
hardening. Cloudflare-protected sites (chatgpt.com, claude.ai,
grok.com, x.com, openai.com) flag Google datacenter IPs as bots and
return Turnstile / CAPTCHA / 502 challenges. Apps Script's UrlFetchApp
exits from those IPs, so v1.9.3 surfaces these as "Relay error: json:
key must be a string..." with no apps_script-mode workaround.
Now a small TypeScript HTTP endpoint (assets/exit_node/valtown.ts)
deployed on val.town / Deno Deploy sits between Apps Script and the
destination. Chain: client → Apps Script (Google IP) → val.town
(non-Google IP) → destination. Destination sees val.town's IP, no
CF challenge.
Config:
"exit_node": {
"enabled": true,
"relay_url": "https://...web.val.run",
"psk": "<openssl rand -hex 32>",
"mode": "selective",
"hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"]
}
Hardening over upstream: PSK fail-closed if still placeholder (fresh
deploy can't be open relay), loop guard (refuses fetch of own host),
explicit 503 on misconfigured. Fallback to direct Apps Script on exit
node failure (CF-affected sites fail, others keep working). Setup
docs in English + Persian at assets/exit_node/README*.md. Example
config at config.exit-node.example.json.
2. Removed the legacy `telegram` job from release.yml. With
TELEGRAM_NOTIFY_ENABLED repo var set to true, every release was
producing two duplicate APK posts on the main Telegram channel: the
old bundled-APK-on-main job AND the newer per-file files-channel
posts (telegram-publish-files.yml). Only the per-file flow is wanted.
Legacy job and its helper telegram_release_notify.py are gone.
Recoverable from git log if anyone needs the bundled pattern back.
169 mhrv-rs lib tests + 33 tunnel-node tests + UI build clean.
163 lines
5.4 KiB
TypeScript
163 lines
5.4 KiB
TypeScript
// mhrv-rs exit node — deploy as an HTTP endpoint on val.town (or Deno
|
|
// Deploy, fly.io, anywhere with a public residential-adjacent IP).
|
|
//
|
|
// Purpose: chain client → Apps Script → this exit node → destination.
|
|
// Apps Script's UrlFetchApp can't reach Cloudflare-protected sites that
|
|
// flag Google datacenter IPs as bots (chatgpt.com, claude.ai, grok.x.ai,
|
|
// many other CF-fronted SaaS). This exit node sits between Apps Script
|
|
// and the destination; the destination sees the exit node's IP (val.town's
|
|
// outbound, generally not flagged as Google datacenter) and accepts the
|
|
// request.
|
|
//
|
|
// Setup:
|
|
// 1. Sign in to https://val.town and create a new HTTP val (TypeScript)
|
|
// 2. Paste the contents of this file
|
|
// 3. Set PSK below to a strong secret (use `openssl rand -hex 32`
|
|
// from a terminal — DO NOT leave the placeholder in production)
|
|
// 4. Save and copy the val's public URL (looks like
|
|
// https://your-handle-mhrv.web.val.run)
|
|
// 5. In mhrv-rs config.json, add:
|
|
// "exit_node": {
|
|
// "enabled": true,
|
|
// "relay_url": "https://your-handle-mhrv.web.val.run",
|
|
// "psk": "<the same PSK you set above>",
|
|
// "mode": "selective",
|
|
// "hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com"]
|
|
// }
|
|
//
|
|
// Threat model: PSK is the only thing keeping this from being an open
|
|
// proxy on the public internet. Treat it like a password: do not commit
|
|
// to source control, do not share publicly, rotate if leaked. The exit
|
|
// node refuses all requests that don't carry the matching PSK.
|
|
//
|
|
// Failure mode: if the exit node is unreachable, mhrv-rs falls back to
|
|
// the regular Apps Script relay automatically — the only consequence
|
|
// of an offline exit node is that ChatGPT/Claude/Grok stop working;
|
|
// other sites are unaffected.
|
|
|
|
const PSK = "CHANGE_ME_TO_A_STRONG_SECRET";
|
|
|
|
// Headers the client may send that must NOT be forwarded to the
|
|
// destination — they're hop-by-hop or would break re-encoding.
|
|
const STRIP_HEADERS = new Set([
|
|
"host",
|
|
"connection",
|
|
"content-length",
|
|
"transfer-encoding",
|
|
"proxy-connection",
|
|
"proxy-authorization",
|
|
"x-forwarded-for",
|
|
"x-forwarded-host",
|
|
"x-forwarded-proto",
|
|
"x-forwarded-port",
|
|
"x-real-ip",
|
|
"forwarded",
|
|
"via",
|
|
]);
|
|
|
|
function decodeBase64ToBytes(input: string): Uint8Array {
|
|
const bin = atob(input);
|
|
const out = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
return out;
|
|
}
|
|
|
|
function encodeBytesToBase64(bytes: Uint8Array): string {
|
|
let bin = "";
|
|
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
return btoa(bin);
|
|
}
|
|
|
|
function sanitizeHeaders(h: unknown): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
if (!h || typeof h !== "object") return out;
|
|
for (const [k, v] of Object.entries(h as Record<string, unknown>)) {
|
|
if (!k) continue;
|
|
if (STRIP_HEADERS.has(k.toLowerCase())) continue;
|
|
out[k] = String(v ?? "");
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export default async function (req: Request): Promise<Response> {
|
|
// Fail closed on the placeholder PSK so a fresh deploy without setup
|
|
// can't accidentally serve as an open relay.
|
|
if (PSK === "CHANGE_ME_TO_A_STRONG_SECRET") {
|
|
return Response.json(
|
|
{
|
|
e:
|
|
"exit_node misconfigured: PSK is still the placeholder. Set " +
|
|
"a strong secret in the val.town source before deploying.",
|
|
},
|
|
{ status: 503 },
|
|
);
|
|
}
|
|
|
|
try {
|
|
if (req.method !== "POST") {
|
|
return Response.json({ e: "method_not_allowed" }, { status: 405 });
|
|
}
|
|
|
|
const body = await req.json();
|
|
if (!body || typeof body !== "object") {
|
|
return Response.json({ e: "bad_json" }, { status: 400 });
|
|
}
|
|
|
|
const k = String((body as any).k ?? "");
|
|
const u = String((body as any).u ?? "");
|
|
const m = String((body as any).m ?? "GET").toUpperCase();
|
|
const h = sanitizeHeaders((body as any).h);
|
|
const b64 = (body as any).b;
|
|
|
|
if (k !== PSK) {
|
|
return Response.json({ e: "unauthorized" }, { status: 401 });
|
|
}
|
|
if (!/^https?:\/\//i.test(u)) {
|
|
return Response.json({ e: "bad url" }, { status: 400 });
|
|
}
|
|
|
|
// Loop guard: if u points at this exit node's own host, refuse.
|
|
// Without this, a misconfigured client could chain exit-node →
|
|
// exit-node → exit-node → ... and burn the val.town runtime budget.
|
|
try {
|
|
const reqUrl = new URL(req.url);
|
|
const dstUrl = new URL(u);
|
|
if (
|
|
reqUrl.host === dstUrl.host &&
|
|
reqUrl.protocol === dstUrl.protocol
|
|
) {
|
|
return Response.json({ e: "exit-node loop refused" }, { status: 400 });
|
|
}
|
|
} catch {
|
|
// Malformed URL — let the fetch below 400.
|
|
}
|
|
|
|
let payload: Uint8Array | undefined;
|
|
if (typeof b64 === "string" && b64.length > 0) {
|
|
payload = decodeBase64ToBytes(b64);
|
|
}
|
|
|
|
const resp = await fetch(u, {
|
|
method: m,
|
|
headers: h,
|
|
body: payload,
|
|
redirect: "manual",
|
|
});
|
|
|
|
const data = new Uint8Array(await resp.arrayBuffer());
|
|
const respHeaders: Record<string, string> = {};
|
|
resp.headers.forEach((value, key) => {
|
|
respHeaders[key] = value;
|
|
});
|
|
|
|
return Response.json({
|
|
s: resp.status,
|
|
h: respHeaders,
|
|
b: encodeBytesToBase64(data),
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return Response.json({ e: message }, { status: 500 });
|
|
}
|
|
}
|