Skip to content

Commit b37c388

Browse files
committed
Consolidate repo around mhrv-rs and add hybrid script templates
1 parent d30ee73 commit b37c388

26 files changed

Lines changed: 637 additions & 3765 deletions

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ Everything the UI does is also available in the CLI. Copy `config.example.json`
181181
"google_ip": "216.239.38.120",
182182
"front_domain": "www.google.com",
183183
"script_id": "PASTE_YOUR_DEPLOYMENT_ID_HERE",
184+
"cfw_script_id": "OPTIONAL_CFW_APPS_SCRIPT_DEPLOYMENT_ID",
185+
"cfw_hosts": ["x.com", ".twitter.com"],
184186
"auth_key": "same-secret-as-in-code-gs",
185187
"listen_host": "127.0.0.1",
186188
"listen_port": 8085,
@@ -190,6 +192,11 @@ Everything the UI does is also available in the CLI. Copy `config.example.json`
190192
}
191193
```
192194

195+
`cfw_script_id` + `cfw_hosts` are optional hybrid-routing knobs: when a request hostname matches `cfw_hosts`, `mhrv-rs` sends only that request through the CFW-backed Apps Script deployment (`assets/apps_script/CodeHybrid.gs`) and keeps everything else on the normal Apps Script deployment.
196+
197+
198+
For a clean one-repo setup, everything you need now lives under `assets/apps_script/` (Apps Script + Worker templates).
199+
193200
Then:
194201

195202
```bash

assets/apps_script/CodeHybrid.gs

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
/**
2+
* MHRV Hybrid Relay (Apps Script + optional Cloudflare Worker)
3+
*
4+
* Client protocol (same as mhrv-rs):
5+
* Single: POST { k, m, u, h, b, ct, r } -> { s, h, b } or { e }
6+
* Batch : POST { k, q: [{m,u,h,b,ct,r}, ...] } -> { q: [{s,h,b}|{e}, ...] }
7+
*
8+
* Routing:
9+
* - Default: direct UrlFetchApp to destination URL
10+
* - Optional CFW path: for hostnames listed in CFW_HOSTS, forward via WORKER_URL
11+
*
12+
* Notes:
13+
* - Keep AUTH_KEY secret and match it in mhrv-rs config.
14+
* - If WORKER_URL is empty, CFW route is effectively disabled.
15+
*/
16+
17+
const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET";
18+
19+
// Optional Cloudflare Worker endpoint (ex: "https://myrelay.workers.dev")
20+
const WORKER_URL = "";
21+
22+
// Optional host routing list for worker path.
23+
// Exact host: "x.com"
24+
// Suffix : ".twitter.com" (matches api.twitter.com)
25+
const CFW_HOSTS = [
26+
// "x.com",
27+
// ".twitter.com",
28+
];
29+
30+
const SKIP_HEADERS = {
31+
host: 1,
32+
connection: 1,
33+
"content-length": 1,
34+
"transfer-encoding": 1,
35+
"proxy-connection": 1,
36+
"proxy-authorization": 1,
37+
"priority": 1,
38+
te: 1,
39+
};
40+
41+
const DECOY_HTML =
42+
'<!DOCTYPE html><html><head><title>Web App</title></head>' +
43+
'<body><p>The script completed but did not return anything.</p></body></html>';
44+
45+
function doPost(e) {
46+
try {
47+
var req = JSON.parse(e.postData.contents);
48+
if (req.k !== AUTH_KEY) return _decoy();
49+
50+
if (Array.isArray(req.q)) return _doBatch(req.q);
51+
return _doSingle(req);
52+
} catch (err) {
53+
return _decoy();
54+
}
55+
}
56+
57+
function doGet(e) {
58+
return ContentService.createTextOutput(DECOY_HTML).setMimeType(ContentService.MimeType.HTML);
59+
}
60+
61+
function _doSingle(req) {
62+
if (!_isValidUrl(req.u)) return _json({ e: "bad url" });
63+
64+
try {
65+
var resp = _fetchRelay(req);
66+
return _json(_packResponse(resp));
67+
} catch (err) {
68+
return _json({ e: String(err) });
69+
}
70+
}
71+
72+
function _doBatch(items) {
73+
var fetchArgs = [];
74+
var indexMap = [];
75+
var results = [];
76+
77+
for (var i = 0; i < items.length; i++) {
78+
var item = items[i];
79+
if (!_isValidUrl(item.u)) {
80+
results[i] = { e: "bad url" };
81+
continue;
82+
}
83+
84+
var built = _buildFetch(item);
85+
fetchArgs.push(built.opts);
86+
indexMap.push({ idx: i, worker: built.worker });
87+
}
88+
89+
if (fetchArgs.length > 0) {
90+
var responses = UrlFetchApp.fetchAll(fetchArgs);
91+
for (var j = 0; j < responses.length; j++) {
92+
var meta = indexMap[j];
93+
try {
94+
if (meta.worker) {
95+
results[meta.idx] = JSON.parse(responses[j].getContentText());
96+
} else {
97+
results[meta.idx] = _packResponse(responses[j]);
98+
}
99+
} catch (err) {
100+
results[meta.idx] = { e: "invalid worker response" };
101+
}
102+
}
103+
}
104+
105+
for (var k = 0; k < items.length; k++) {
106+
if (!results[k]) results[k] = { e: "unknown" };
107+
}
108+
109+
return _json({ q: results });
110+
}
111+
112+
function _fetchRelay(req) {
113+
var built = _buildFetch(req);
114+
var resp = UrlFetchApp.fetch(built.url, built.opts);
115+
if (!built.worker) return resp;
116+
117+
var txt = resp.getContentText();
118+
return {
119+
_worker: true,
120+
_parsed: JSON.parse(txt),
121+
};
122+
}
123+
124+
function _packResponse(resp) {
125+
if (resp && resp._worker) return resp._parsed;
126+
return {
127+
s: resp.getResponseCode(),
128+
h: _respHeaders(resp),
129+
b: Utilities.base64Encode(resp.getContent()),
130+
};
131+
}
132+
133+
function _buildFetch(req) {
134+
var useWorker = _shouldUseWorker(req.u);
135+
if (!useWorker) {
136+
return {
137+
url: req.u,
138+
worker: false,
139+
opts: _buildDirectOpts(req),
140+
};
141+
}
142+
143+
if (!WORKER_URL) {
144+
throw new Error("WORKER_URL is empty but request matched CFW_HOSTS");
145+
}
146+
147+
return {
148+
url: WORKER_URL,
149+
worker: true,
150+
opts: {
151+
method: "post",
152+
contentType: "application/json",
153+
payload: JSON.stringify(_buildWorkerPayload(req)),
154+
muteHttpExceptions: true,
155+
followRedirects: true,
156+
validateHttpsCertificates: true,
157+
escaping: false,
158+
},
159+
};
160+
}
161+
162+
function _buildDirectOpts(req) {
163+
var opts = {
164+
method: (req.m || "GET").toLowerCase(),
165+
muteHttpExceptions: true,
166+
followRedirects: req.r !== false,
167+
validateHttpsCertificates: true,
168+
escaping: false,
169+
};
170+
var headers = _filteredHeaders(req.h);
171+
if (Object.keys(headers).length > 0) opts.headers = headers;
172+
if (req.b) {
173+
opts.payload = Utilities.base64Decode(req.b);
174+
if (req.ct) opts.contentType = req.ct;
175+
}
176+
return opts;
177+
}
178+
179+
function _buildWorkerPayload(req) {
180+
return {
181+
u: req.u,
182+
m: (req.m || "GET").toUpperCase(),
183+
h: _filteredHeaders(req.h),
184+
b: req.b || null,
185+
ct: req.ct || null,
186+
r: req.r !== false,
187+
};
188+
}
189+
190+
function _filteredHeaders(inHeaders) {
191+
var headers = {};
192+
if (!inHeaders || typeof inHeaders !== "object") return headers;
193+
194+
for (var k in inHeaders) {
195+
if (!inHeaders.hasOwnProperty(k)) continue;
196+
if (SKIP_HEADERS[k.toLowerCase()]) continue;
197+
headers[k] = inHeaders[k];
198+
}
199+
return headers;
200+
}
201+
202+
function _shouldUseWorker(url) {
203+
if (!CFW_HOSTS || CFW_HOSTS.length === 0) return false;
204+
205+
var host;
206+
try {
207+
host = _hostFromUrl(url);
208+
} catch (_) {
209+
return false;
210+
}
211+
212+
for (var i = 0; i < CFW_HOSTS.length; i++) {
213+
var entry = String(CFW_HOSTS[i] || "").trim().toLowerCase().replace(/\.+$/, "");
214+
if (!entry) continue;
215+
if (entry.charAt(0) === ".") {
216+
var suffix = entry.slice(1);
217+
if (!suffix) continue;
218+
if (host === suffix || host.endsWith("." + suffix)) return true;
219+
} else {
220+
if (host === entry) return true;
221+
}
222+
}
223+
return false;
224+
}
225+
226+
function _hostFromUrl(url) {
227+
var m = String(url || "").match(/^https?:\/\/([^\/]+)/i);
228+
if (!m) throw new Error("invalid url");
229+
var authority = m[1].toLowerCase();
230+
var noAuth = authority.indexOf("@") >= 0 ? authority.split("@").pop() : authority;
231+
if (noAuth.charAt(0) === "[") {
232+
var r = noAuth.indexOf("]");
233+
return (r > 0 ? noAuth.slice(1, r) : noAuth).replace(/\.+$/, "");
234+
}
235+
return noAuth.split(":")[0].replace(/\.+$/, "");
236+
}
237+
238+
function _isValidUrl(u) {
239+
return typeof u === "string" && /^https?:\/\//i.test(u);
240+
}
241+
242+
function _respHeaders(resp) {
243+
try {
244+
if (typeof resp.getAllHeaders === "function") {
245+
return resp.getAllHeaders();
246+
}
247+
} catch (_) {}
248+
return resp.getHeaders();
249+
}
250+
251+
function _decoy() {
252+
return ContentService.createTextOutput(DECOY_HTML).setMimeType(ContentService.MimeType.HTML);
253+
}
254+
255+
function _json(obj) {
256+
return ContentService
257+
.createTextOutput(JSON.stringify(obj))
258+
.setMimeType(ContentService.MimeType.JSON);
259+
}

assets/apps_script/README.md

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
1-
# Apps Script source (mirrored)
1+
# Apps Script / Worker templates for `mhrv-rs`
22

3-
The file `Code.gs` next to this README is a verbatim snapshot of the upstream script you deploy in your own Google Apps Script project:
3+
This folder contains deploy-ready scripts used by the Rust client.
44

5-
- Upstream: <https://github.com/masterking32/MasterHttpRelayVPN/blob/python_testing/apps_script/Code.gs>
6-
- Raw link: <https://raw.githubusercontent.com/masterking32/MasterHttpRelayVPN/refs/heads/python_testing/apps_script/Code.gs>
5+
## Files
76

8-
This copy lives in our repo for two reasons:
7+
- `Code.gs` — upstream-compatible direct Apps Script relay.
8+
- `CodeFull.gs` — full-mode tunnel relay script (for `mode = "full"`).
9+
- `CodeHybrid.gs` — new hybrid relay script:
10+
- default route: direct `UrlFetchApp` (normal Apps Script behavior)
11+
- optional route: forwards selected hostnames to your Cloudflare Worker
12+
- `worker.js` — minimal Cloudflare Worker endpoint that accepts the same relay payload and returns `{s,h,b}`.
913

10-
1. **Survives upstream outages**: if the user is on a network where raw.githubusercontent.com is temporarily unreachable but they can clone or ZIP this repo, they still have the deploy-ready file.
11-
2. **Pins what we tested against**: the relay protocol between `mhrv-rs` and the script is informal; upstream changes can silently break us. Keeping a snapshot here lets us diff and see if a spec drift is responsible for any reported breakage.
14+
## When to use which
1215

13-
All credit for `Code.gs` goes to [@masterking32](https://github.com/masterking32) — we do not modify it. If you're using mhrv-rs, follow the upstream deploy instructions in the script's header comment. The only edit **you** must make is the `AUTH_KEY` constant — set it to a strong secret and reuse that exact string in your `mhrv-rs` config.
16+
- Want classic setup only: deploy **`Code.gs`**.
17+
- Want full tunnel mode: deploy **`CodeFull.gs`**.
18+
- Want mixed routing (normal via Apps Script + specific hosts via CFW): deploy **`CodeHybrid.gs`** and configure:
19+
- `WORKER_URL`, `CFW_HOSTS` in script
20+
- `cfw_script_id` / `cfw_hosts` in `mhrv-rs` config
21+
22+
## Security notes
23+
24+
- Always change `AUTH_KEY` before deployment.
25+
- Keep Worker URL private if possible.
26+
- Do not share deployment IDs and auth key publicly.

config.example.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
"google_ip": "216.239.38.120",
44
"front_domain": "www.google.com",
55
"script_id": "YOUR_APPS_SCRIPT_DEPLOYMENT_ID",
6+
"cfw_script_id": "",
7+
"cfw_hosts": [],
68
"auth_key": "CHANGE_ME_TO_A_STRONG_SECRET",
79
"listen_host": "127.0.0.1",
810
"listen_port": 8085,

config.full.example.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
"google_ip": "216.239.38.120",
44
"front_domain": "www.google.com",
55
"script_id": "YOUR_APPS_SCRIPT_DEPLOYMENT_ID",
6+
"cfw_script_id": "",
7+
"cfw_hosts": [],
68
"auth_key": "CHANGE_ME_TO_A_STRONG_SECRET",
79
"listen_host": "127.0.0.1",
810
"listen_port": 8085,

mhr-cfw/.dockerignore

Whitespace-only changes.

mhr-cfw/.gitignore

Lines changed: 0 additions & 35 deletions
This file was deleted.

mhr-cfw/Dockerfile

Whitespace-only changes.

0 commit comments

Comments
 (0)