-
Notifications
You must be signed in to change notification settings - Fork 492
Expand file tree
/
Copy pathexit_node.ts
More file actions
175 lines (160 loc) · 6.32 KB
/
exit_node.ts
File metadata and controls
175 lines (160 loc) · 6.32 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
165
166
167
168
169
170
171
172
173
174
175
// 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",
});
// `fetch()` (Deno / Bun / Node) auto-decompresses gzip / br / deflate
// responses, so `resp.arrayBuffer()` returns plain bytes — but the
// destination's `Content-Encoding` header is still on `resp.headers`.
// Forwarding it would tell the client browser "this body is gzipped"
// when it isn't, producing `Content Encoding Error` (#964). Same goes
// for `Content-Length` — the post-decompression byte count is
// different from what the destination announced. Strip both. The
// Apps Script + Rust transport layer below us re-frames the wire body
// anyway, so neither header is meaningful to forward.
const data = new Uint8Array(await resp.arrayBuffer());
const respHeaders: Record<string, string> = {};
resp.headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (lower === "content-encoding" || lower === "content-length") return;
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 });
}
}