Skip to content

Commit 9dc7058

Browse files
committed
fix(web): proxy gateway calls through the Worker (fixes "no live gateway")
The web app is https but gateways serve their control API over plain http (:51821, signed bodies not TLS), so the browser's mixed-content block stopped it from probing /v1/info (every country showed "seed") or POSTing enroll — hence "No live gateway reachable ... https pages can't reach". Desktop/mobile aren't browsers, so they were unaffected. Route those calls through the site's own Cloudflare Worker instead: - worker.js: proxies `/gw/<ip>:<port>/<path>` → `http://<ip>:<port>/<path>` (same-origin https → the browser can read the signed response headers). SSRF-guarded: only the gateway (51821) + Flux node (16127) ports, public IPv4 only. Serves the static site via the ASSETS binding otherwise. - wrangler.jsonc: add `main` + `assets.binding: ASSETS` + `run_worker_first` (so /gw/* reaches the Worker before the SPA asset fallback would). - web: `proxiedFetch` rewrites plain-http gateway/node URLs to `/gw/…`; injected into discovery + single/multi-hop enroll. https URLs pass through untouched. Validated with `wrangler deploy --dry-run` (worker bundles, ASSETS bound).
1 parent 0b0db2c commit 9dc7058

6 files changed

Lines changed: 119 additions & 11 deletions

File tree

clients/web/src/components/MultihopSection.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ApiError, buildMultihopConfig, enroll, selectHops } from '@cumulusvpn/c
33
import type { Keypair, MultihopConfig, RouteStyle } from '@cumulusvpn/core';
44
import type { DiscoveryState } from '../hooks/useDiscovery';
55
import { downloadText } from '../lib/download';
6+
import { proxiedFetch } from '../lib/gatewayFetch';
67

78
interface MultihopSectionProps {
89
readonly keypair: Keypair;
@@ -89,8 +90,14 @@ export function MultihopSection({ keypair, discovery }: MultihopSectionProps) {
8990
// Enroll the SAME key K at both gateways — entitlement follows the key on
9091
// every gateway, so one payment covers both hops (no gateway change).
9192
const [entryEnroll, exitEnroll] = await Promise.all([
92-
enroll(hops.entry.ip, keypair.publicKey, { signPubKey: hops.entry.sign_pubkey }),
93-
enroll(exitGw.ip, keypair.publicKey, { signPubKey: exitGw.sign_pubkey }),
93+
enroll(hops.entry.ip, keypair.publicKey, {
94+
signPubKey: hops.entry.sign_pubkey,
95+
fetchImpl: proxiedFetch,
96+
}),
97+
enroll(exitGw.ip, keypair.publicKey, {
98+
signPubKey: exitGw.sign_pubkey,
99+
fetchImpl: proxiedFetch,
100+
}),
94101
]);
95102

96103
const config = buildMultihopConfig({

clients/web/src/hooks/useDiscovery.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { resolveDirectory } from '../lib/directory';
55
import type { DirectorySource } from '../lib/directory';
66
import { buildCountryOptions } from '../lib/gateways';
77
import type { CountryOption } from '../lib/gateways';
8+
import { proxiedFetch } from '../lib/gatewayFetch';
89

910
export interface DiscoveryState {
1011
readonly loading: boolean;
@@ -50,7 +51,7 @@ export function useDiscovery(): DiscoveryState {
5051

5152
let gateways: GatewayInfo[] = [];
5253
try {
53-
gateways = await discoverGateways(directory.specs);
54+
gateways = await discoverGateways(directory.specs, { fetchImpl: proxiedFetch });
5455
} catch {
5556
gateways = [];
5657
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Same-origin proxy fetch for gateway / Flux-node calls.
3+
*
4+
* `vpn.cumulusvpn.com` is served over https, but the gateways expose their
5+
* control API over plain http (`:51821`, signed bodies instead of TLS), and the
6+
* Flux node index is http (`:16127`). Browsers BLOCK http requests from an https
7+
* page (mixed content), so a browser can neither probe `/v1/info` (every country
8+
* shows as "seed") nor POST enroll.
9+
*
10+
* We route those calls through the site's own Cloudflare Worker instead:
11+
* `http://<ip>:<port>/<path>` → `/gw/<ip>:<port>/<path>` (same origin, https)
12+
* The Worker forwards them to the gateway server-side (see clients/web/worker.js).
13+
* https URLs (the Flux public API) pass through untouched.
14+
*/
15+
export const proxiedFetch: typeof fetch = (input, init) => {
16+
const url =
17+
typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url;
18+
if (url.startsWith('http://')) {
19+
return fetch(`/gw/${url.slice('http://'.length)}`, init);
20+
}
21+
return fetch(input, init);
22+
};

clients/web/src/pages/ConnectPage.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { EnrollResponse, Keypair } from '@cumulusvpn/core';
44
import type { CountryOption } from '../lib/gateways';
55
import type { DiscoveryState } from '../hooks/useDiscovery';
66
import { downloadText } from '../lib/download';
7+
import { proxiedFetch } from '../lib/gatewayFetch';
78
import { CountryPicker } from '../components/CountryPicker';
89
import { CopyField } from '../components/CopyField';
910
import { MultihopSection } from '../components/MultihopSection';
@@ -66,7 +67,10 @@ export function ConnectPage({
6667
setError(null);
6768
setResult(null);
6869
try {
69-
const data = await enroll(gw.ip, keypair.publicKey, { signPubKey: gw.sign_pubkey });
70+
const data = await enroll(gw.ip, keypair.publicKey, {
71+
signPubKey: gw.sign_pubkey,
72+
fetchImpl: proxiedFetch,
73+
});
7074
const config = buildWgConfig({
7175
privateKey: keypair.privateKey,
7276
assignedIp: data.assigned_ip,

clients/web/worker.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Cloudflare Worker for vpn.cumulusvpn.com.
3+
*
4+
* Serves the built static site (via the ASSETS binding) AND proxies the web
5+
* app's gateway calls so an https page can reach the plain-http gateway control
6+
* API without a mixed-content block:
7+
*
8+
* GET/POST /gw/<ip>:<port>/<path> → http://<ip>:<port>/<path>
9+
*
10+
* The gateway signs its response bodies, and this is same-origin, so the browser
11+
* can read the signature headers and verify as usual.
12+
*
13+
* SSRF guard: only the gateway control port + the Flux node port, and only
14+
* public IPv4 targets, so it can't be used as an open relay to arbitrary hosts
15+
* or internal addresses. (A tighter follow-up: allowlist IPs from the signed
16+
* directory.)
17+
*/
18+
19+
const ALLOWED_PORTS = new Set(['51821', '16127']);
20+
21+
/** True only for a routable public IPv4 literal (blocks private / loopback / link-local). */
22+
function isPublicIPv4(host) {
23+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
24+
if (!m) return false;
25+
const o = [m[1], m[2], m[3], m[4]].map(Number);
26+
if (o.some((n) => n > 255)) return false;
27+
const [a, b] = o;
28+
if (a === 0 || a === 10 || a === 127) return false; // this-network / private / loopback
29+
if (a === 169 && b === 254) return false; // link-local
30+
if (a === 172 && b >= 16 && b <= 31) return false; // private
31+
if (a === 192 && b === 168) return false; // private
32+
return true;
33+
}
34+
35+
export default {
36+
async fetch(request, env) {
37+
const url = new URL(request.url);
38+
39+
if (url.pathname.startsWith('/gw/')) {
40+
const rest = url.pathname.slice('/gw/'.length);
41+
const slash = rest.indexOf('/');
42+
const authority = slash === -1 ? rest : rest.slice(0, slash);
43+
const path = slash === -1 ? '/' : rest.slice(slash);
44+
const [host, port] = authority.split(':');
45+
46+
if (!host || !port || !ALLOWED_PORTS.has(port) || !isPublicIPv4(host)) {
47+
return new Response('proxy target not allowed', { status: 403 });
48+
}
49+
50+
const target = `http://${host}:${port}${path}${url.search}`;
51+
const init = { method: request.method, headers: {} };
52+
const ct = request.headers.get('content-type');
53+
if (ct) init.headers['content-type'] = ct;
54+
if (request.method !== 'GET' && request.method !== 'HEAD') {
55+
init.body = await request.text();
56+
}
57+
try {
58+
return await fetch(target, init);
59+
} catch {
60+
return new Response('gateway unreachable', { status: 502 });
61+
}
62+
}
63+
64+
// Everything else: the static site (with SPA/hash-routing fallback).
65+
return env.ASSETS.fetch(request);
66+
},
67+
};

wrangler.jsonc

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
1-
// Cloudflare Workers (Static Assets) config for the CumulusVPN web app (vpn.cumulusvpn.com).
2-
// Serves the built SPA in clients/web/dist as a static site — no Worker code, just assets.
1+
// Cloudflare Worker for the CumulusVPN web app (vpn.cumulusvpn.com).
2+
// Serves the built SPA in clients/web/dist AND runs worker.js, which proxies the
3+
// app's plain-http gateway calls (/gw/<ip>:<port>/…) so an https page can reach
4+
// them without a mixed-content block. See clients/web/worker.js.
35
//
4-
// Cloudflare "Build command" (set in the dashboard):
5-
// corepack enable && yarn install && yarn workspace @cumulusvpn/core build && yarn workspace @cumulusvpn/web build
6-
// Deploy command: npx wrangler deploy (reads this file, uploads clients/web/dist)
6+
// Cloudflare "Build command" (dashboard):
7+
// corepack enable && yarn install && yarn build:web
8+
// Deploy command: npx wrangler deploy (reads this file)
79
{
810
"name": "cumulusvpn",
11+
"main": "clients/web/worker.js",
912
"compatibility_date": "2026-07-17",
1013
"assets": {
1114
"directory": "clients/web/dist",
12-
// SPA fallback: unknown paths return index.html (the app uses hash routing,
13-
// so this is just belt-and-suspenders).
15+
// The Worker serves assets through this binding (env.ASSETS.fetch).
16+
"binding": "ASSETS",
17+
// run_worker_first: the Worker runs on every request so it can intercept the
18+
// /gw/* proxy routes before asset routing (incl. the SPA fallback) would.
19+
"run_worker_first": true,
20+
// SPA fallback: unknown paths return index.html (the app uses hash routing).
1421
"not_found_handling": "single-page-application"
1522
}
1623
}

0 commit comments

Comments
 (0)