mirror of
https://github.com/masterking32/MasterHttpRelayVPN.git
synced 2026-05-17 21:24:37 +03:00
152 lines
4.8 KiB
JavaScript
152 lines
4.8 KiB
JavaScript
// MasterHttpRelay exit node for Cloudflare Workers.
|
|
// Deploy as HTTP endpoint and set PSK to a strong secret.
|
|
|
|
const PSK = "CHANGE_ME_TO_A_STRONG_SECRET";
|
|
|
|
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",
|
|
// Internal relay hop header — must not propagate to the final target.
|
|
"x-mhr-hop",
|
|
// Workers cannot decompress gzip/br/deflate — stripping accept-encoding
|
|
// forces targets to reply with plain bodies the Worker can forward as-is.
|
|
"accept-encoding",
|
|
]);
|
|
|
|
function decodeBase64ToBytes(input) {
|
|
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) {
|
|
let bin = "";
|
|
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
return btoa(bin);
|
|
}
|
|
|
|
function sanitizeHeaders(h) {
|
|
const out = {};
|
|
if (!h || typeof h !== "object") return out;
|
|
for (const [k, v] of Object.entries(h)) {
|
|
if (!k) continue;
|
|
if (STRIP_HEADERS.has(k.toLowerCase())) continue;
|
|
out[k] = String(v ?? "");
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export default {
|
|
async fetch(req) {
|
|
try {
|
|
// Cloudflare dashboard and browsers commonly test a Worker with GET.
|
|
// Return a friendly health response so users don't misread it as failure.
|
|
if (req.method === "GET") {
|
|
return Response.json(
|
|
{
|
|
ok: true,
|
|
status: "healthy",
|
|
message: "Everything is OK. Worker is deployed and reachable.",
|
|
usage: "Send POST with relay payload for actual proxy requests.",
|
|
},
|
|
{ status: 200 }
|
|
);
|
|
}
|
|
|
|
if (req.method !== "POST") {
|
|
return Response.json(
|
|
{
|
|
e: "method_not_allowed",
|
|
message: "Use POST for relay requests. GET is only a health check.",
|
|
},
|
|
{ status: 405 }
|
|
);
|
|
}
|
|
|
|
const body = await req.json();
|
|
if (!body || typeof body !== "object") {
|
|
return Response.json({ e: "bad_json" }, { status: 400 });
|
|
}
|
|
|
|
if (!PSK) {
|
|
return Response.json({ e: "server_psk_missing" }, { status: 500 });
|
|
}
|
|
|
|
const k = String(body.k ?? "");
|
|
const u = String(body.u ?? "");
|
|
const m = String(body.m ?? "GET").toUpperCase();
|
|
const h = sanitizeHeaders(body.h);
|
|
const b64 = body.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 detection ────────────────────────────────────────────────────
|
|
// Case 1 — self-loop: target URL resolves back to this Worker.
|
|
// Happens when a user sets exit_node_url to the Worker URL itself.
|
|
try {
|
|
const targetHost = new URL(u).hostname.toLowerCase();
|
|
const workerHost = new URL(req.url).hostname.toLowerCase();
|
|
if (targetHost === workerHost) {
|
|
return Response.json(
|
|
{ e: "loop_detected", detail: "target URL resolves to this Worker" },
|
|
{ status: 508 }
|
|
);
|
|
}
|
|
} catch (_) {
|
|
// Malformed URL already caught by the regex above; ignore parse errors.
|
|
}
|
|
// Case 2 — GAS→Worker→GAS loop: the incoming request was relayed from
|
|
// a Google Apps Script instance (x-mhr-hop header set), and the
|
|
// target is another Apps Script script URL.
|
|
// Without this guard, a misconfigured chain would bounce between
|
|
// Apps Script and Cloudflare until quota is exhausted.
|
|
const hopHeader = req.headers.get("x-mhr-hop");
|
|
if (hopHeader && /\/macros\/s\//i.test(u)) {
|
|
return Response.json(
|
|
{ e: "loop_detected", detail: "GAS→Worker→GAS relay loop" },
|
|
{ status: 508 }
|
|
);
|
|
}
|
|
|
|
let payload;
|
|
if (typeof b64 === "string" && b64.length > 0) payload = decodeBase64ToBytes(b64);
|
|
const requestBody = payload ? Uint8Array.from(payload) : undefined;
|
|
|
|
const resp = await fetch(u, {
|
|
method: m,
|
|
headers: h,
|
|
body: requestBody,
|
|
redirect: "manual",
|
|
});
|
|
|
|
const data = new Uint8Array(await resp.arrayBuffer());
|
|
const respHeaders = {};
|
|
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 });
|
|
}
|
|
},
|
|
};
|