-
Notifications
You must be signed in to change notification settings - Fork 233
Expand file tree
/
Copy pathexit_node.ts
More file actions
164 lines (149 loc) · 5.57 KB
/
exit_node.ts
File metadata and controls
164 lines (149 loc) · 5.57 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
// mhrv-rs exit node — deploy as an HTTP endpoint on any serverless
// TypeScript host with a public IP that isn't a Google datacenter
// (Deno Deploy, fly.io, your own VPS, etc.). Uses only web-standard
// `Request` / `Response` / `fetch` so it's portable across runtimes.
//
// 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.com,
// many other CF-fronted SaaS). This exit node sits between Apps Script
// and the destination; the destination sees the exit node's outbound IP
// (generally not flagged as Google datacenter) and accepts the request.
//
// Setup:
// 1. Pick a host that runs web-standard fetch handlers (e.g. Deno
// Deploy, fly.io with a thin server wrapper, or any cheap VPS
// running Deno / Node + this script as a handler).
// 2. Paste the contents of this file as the request handler.
// 3. Set PSK below to a strong secret (`openssl rand -hex 32` from
// a terminal — DO NOT leave the placeholder in production).
// 4. Deploy and copy the public URL of the deployed handler.
// 5. In mhrv-rs config.json, add:
// "exit_node": {
// "enabled": true,
// "relay_url": "https://your-deployed-exit-node.example.com",
// "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 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 host's 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 });
}
}