diff --git a/README.md b/README.md index 8778806..8e38620 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ A free tool that lets you access the internet freely by hiding your traffic behi > **How it works in simple terms:** Your browser talks to this tool on your computer. This tool disguises your traffic to look like normal Google traffic. The firewall/filter sees "google.com" and lets it pass. Behind the scenes, a free Google Apps Script relay fetches the real website for you. - --- ## Announcement and Support Channel πŸ“’ @@ -74,6 +73,7 @@ One command sets up a virtualenv, installs dependencies, launches an interactive config wizard, and starts the proxy. **Windows:** + ```cmd git clone https://github.com/masterking32/MasterHttpRelayVPN.git cd MasterHttpRelayVPN @@ -81,6 +81,7 @@ start.bat ``` **Linux / macOS:** + ```bash git clone https://github.com/masterking32/MasterHttpRelayVPN.git cd MasterHttpRelayVPN @@ -93,7 +94,6 @@ and generates a strong random password for you. Follow the Apps Script deploymen instructions in **Step 2** below before running the wizard so you have a Deployment ID ready. - ## Step-by-Step Setup Guide (Manual) ### Step 1: Download This Project @@ -105,6 +105,7 @@ pip install -r requirements.txt ``` > **Can't reach PyPI directly?** Use this mirror instead: +> > ```bash > pip install -r requirements.txt -i https://mirror-pypi.runflare.com/simple/ --trusted-host mirror-pypi.runflare.com > ``` @@ -138,21 +139,25 @@ This is the "relay" that sits on Google's servers and fetches websites for you. ### Step 3: Configure **Option A β€” interactive wizard (recommended):** + ```bash python setup.py ``` + It'll prompt for your Deployment ID, generate a random `auth_key`, and write `config.json` for you. **Option B β€” manual:** 1. Copy the example config file: + ```bash cp config.example.json config.json ``` - On Windows, you can also just copy & rename the file manually. + On Windows, you can also just copy & rename the file manually. 2. Open `config.json` in any text editor and fill in your values: + ```json { "mode": "apps_script", @@ -168,6 +173,7 @@ It'll prompt for your Deployment ID, generate a random `auth_key`, and write "verify_ssl": true } ``` + - `script_id` β†’ Paste the Deployment ID from Step 2. - `auth_key` β†’ The **same password** you set in `Code.gs`. @@ -187,6 +193,7 @@ You can deploy any one of these exit-node backends: 3. Your own VPS server Full step-by-step deployment guide (all providers): + - [docs/exit-node/EXIT_NODE_DEPLOYMENT.md](docs/exit-node/EXIT_NODE_DEPLOYMENT.md) Set the same PSK secret inside the exit-node code (`PSK` constant) and in `config.json`. @@ -210,6 +217,7 @@ Then configure provider switching like this: ``` Notes: + - For simple setup, only fill `provider`, `url`, and `psk`. - Switch provider by changing `exit_node.provider` and `exit_node.url`. - `mode: "full"` = everything goes through exit node (ignore `hosts`). @@ -217,6 +225,7 @@ Notes: - `psk` must exactly match your deployed exit node secret. Production recommendation: + - Keep `verify_ssl: true` - Keep `listen_host: 127.0.0.1` unless LAN sharing is explicitly needed - Rotate both secrets periodically @@ -240,6 +249,7 @@ Set your browser to use the proxy: - **Optional SOCKS5 Port:** `1080` **How to set proxy in common browsers:** + - **Firefox:** Settings β†’ General β†’ Network Settings β†’ Manual proxy β†’ enter `127.0.0.1` port `8085` for HTTP Proxy β†’ check "Also use this proxy for HTTPS" - **Chrome/Edge:** Uses system proxy. Go to Windows Settings β†’ Network β†’ Proxy β†’ Manual setup β†’ enter `127.0.0.1:8085` - **Or** use extensions like [FoxyProxy](https://addons.mozilla.org/en-US/firefox/addon/foxyproxy-standard/) or [SwitchyOmega](https://chrome.google.com/webstore/detail/proxy-switchyomega/) for easier switching. @@ -251,6 +261,7 @@ When using `apps_script` mode, the tool needs to decrypt and re-encrypt HTTPS tr The certificate file is created at `ca/ca.crt` inside the project folder after the first run. #### Windows + 1. Double-click `ca/ca.crt`. 2. Click **Install Certificate**. 3. Choose **Current User** (or Local Machine for all users). @@ -259,6 +270,7 @@ The certificate file is created at `ca/ca.crt` inside the project folder after t 6. Restart your browser. #### macOS + 1. Double-click `ca/ca.crt` β€” it opens in Keychain Access. 2. It goes into the **login** keychain. 3. Find the certificate, double-click it. @@ -267,14 +279,18 @@ The certificate file is created at `ca/ca.crt` inside the project folder after t 6. Close and enter your password. Restart your browser. #### Linux (Ubuntu/Debian) + ```bash sudo cp ca/ca.crt /usr/local/share/ca-certificates/masterhttp-relay.crt sudo update-ca-certificates ``` + Restart your browser. #### Firefox (All Platforms) + Firefox uses its own certificate store, so even after OS-level install you need to do this: + 1. Go to **Settings** β†’ **Privacy & Security** β†’ **Certificates** β†’ **View Certificates**. 2. Go to the **Authorities** tab β†’ click **Import**. 3. Select `ca/ca.crt` from the project folder. @@ -297,6 +313,7 @@ By default, the proxy only listens on `127.0.0.1` (localhost), meaning only your 3. The startup log will show your LAN IP addresses that other devices can connect to **Example LAN configuration:** + ```json { "lan_sharing": true, @@ -309,6 +326,31 @@ By default, the proxy only listens on `127.0.0.1` (localhost), meaning only your **On other devices:** Configure them to use your computer's LAN IP (shown in the startup log) and port 8085 as the HTTP proxy. + + +## TCP Relay + +To use many protocols such as ssh and tor, http relay is not suitable, Because http is an application layer protocol. So your going to need tcp to use protocols which are built upon it. + +Using below config you could relay your traffic on tcp using websocket. + +``` + "tcp_ws_relay": { + "enabled": false, + "ws_url": "wss://your-tcp-relay.deno.dev/tcp", + "auth_key": "CHANGE_ME_TO_A_STRONG_SECRET", + "front_domain": null, + "front_ip": null, + "connect_timeout": 15, + "ping_interval": 20 + } + +``` + +The app_script for cloudflare relay is the same (cloudflare_worker.js). There is also a deno tcp relay code available (deno_tcp_relay.ts). To deploy, check the deployment part of this document. + +After setting this up, using the socks5 proxy all tour traffic routes through tcp relay. If the tcp relay fails, It falls back to http relay. + --- ## Modes Overview @@ -321,51 +363,53 @@ This project is centered on the **Apps Script** relay (free, no VPS needed). For ### Main Settings -| Setting | What It Does | -|---------|-------------| -| `auth_key` | Password shared between your computer and the relay | -| `script_id` | Your Google Apps Script Deployment ID | -| `listen_host` | Where to listen (`127.0.0.1` = only this computer, `0.0.0.0` = all interfaces for LAN sharing) | -| `listen_port` | Which port to listen on (default: `8085`) | + +| Setting | What It Does | +| ------------- | ----------------------------------------------------------------------------------------------- | +| `auth_key` | Password shared between your computer and the relay | +| `script_id` | Your Google Apps Script Deployment ID | +| `listen_host` | Where to listen (`127.0.0.1` = only this computer, `0.0.0.0` = all interfaces for LAN sharing) | +| `listen_port` | Which port to listen on (default:`8085`) | | `lan_sharing` | Enable LAN sharing to allow other devices on your network to use the proxy (`false` by default) | -| `log_level` | How much detail to show: `DEBUG`, `INFO`, `WARNING`, `ERROR` | +| `log_level` | How much detail to show:`DEBUG`, `INFO`, `WARNING`, `ERROR` | ### Advanced Settings -| Setting | Default | What It Does | -|---------|---------|-------------| -| `google_ip` | `216.239.38.120` | Google IP address to connect through | -| `front_domain` | `www.google.com` | Domain shown to the firewall/filter | -| `verify_ssl` | `true` | Verify the TLS certificate on the local fronted connection to Google/CDN | -| `relay_timeout` | `25` | Total timeout for one relayed request before it fails | -| `tls_connect_timeout` | `15` | Timeout for the proxy's TLS connection to the fronted Google/CDN endpoint | -| `tcp_connect_timeout` | `10` | Timeout for direct TCP tunnels and outbound SNI-rewrite connects | -| `max_response_body_bytes` | `209715200` | Hard cap for a single relay response body after buffering/decoding | -| `script_ids` | β€” | Multiple Script IDs for load balancing (array) | -| `chunked_download_extensions` | see [config.example.json](config.example.json) | File extensions that should use parallel range downloading. Supports `".*"` to probe all GET downloads. | -| `chunked_download_min_size` | `5242880` | Minimum total file size (5 MB) before range-parallel download stays enabled | -| `chunked_download_chunk_size` | `524288` | Per-range chunk size used by parallel downloads | -| `chunked_download_max_parallel` | `8` | Maximum simultaneous range requests for one download | -| `chunked_download_max_chunks` | `256` | Soft upper bound for total chunk requests; chunk size is raised automatically for very large files | -| `block_hosts` | `[]` | Hosts that must never be tunneled (return HTTP 403). Supports exact names (`ads.example.com`) or leading-dot suffixes (`.doubleclick.net`). | -| `bypass_hosts` | `["localhost", ".local", ".lan", ".home.arpa"]` | Hosts that go direct (no MITM, no relay). Useful for LAN resources or sites that break under MITM. | -| `direct_google_exclude` | see [config.example.json](config.example.json) | Google apps that must use the MITM relay path instead of the fast direct tunnel. | -| `hosts` | `{}` | Manual DNS override: map a hostname to a specific IP. | -| `youtube_via_relay` | `false` | Route YouTube (`youtube.com`, `youtu.be`, `youtube-nocookie.com`) through the Apps Script relay instead of the SNI-rewrite path. The SNI-rewrite path uses Google's frontend IP which enforces SafeSearch and can cause **"Video Unavailable"** errors. Setting this to `true` fixes playback at the cost of using more Apps Script executions and slightly higher latency. | -| `exit_node.provider` | `cloudflare` | Selected exit-node backend: `cloudflare`, `deno`, `vps`, or `custom`. | -| `exit_node.url` | `""` | Beginner-friendly single URL for the selected provider. | + +| Setting | Default | What It Does | +| ------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `google_ip` | `216.239.38.120` | Google IP address to connect through | +| `front_domain` | `www.google.com` | Domain shown to the firewall/filter | +| `verify_ssl` | `true` | Verify the TLS certificate on the local fronted connection to Google/CDN | +| `relay_timeout` | `25` | Total timeout for one relayed request before it fails | +| `tls_connect_timeout` | `15` | Timeout for the proxy's TLS connection to the fronted Google/CDN endpoint | +| `tcp_connect_timeout` | `10` | Timeout for direct TCP tunnels and outbound SNI-rewrite connects | +| `max_response_body_bytes` | `209715200` | Hard cap for a single relay response body after buffering/decoding | +| `script_ids` | β€” | Multiple Script IDs for load balancing (array) | +| `chunked_download_extensions` | see[config.example.json](config.example.json) | File extensions that should use parallel range downloading. Supports`".*"` to probe all GET downloads. | +| `chunked_download_min_size` | `5242880` | Minimum total file size (5 MB) before range-parallel download stays enabled | +| `chunked_download_chunk_size` | `524288` | Per-range chunk size used by parallel downloads | +| `chunked_download_max_parallel` | `8` | Maximum simultaneous range requests for one download | +| `chunked_download_max_chunks` | `256` | Soft upper bound for total chunk requests; chunk size is raised automatically for very large files | +| `block_hosts` | `[]` | Hosts that must never be tunneled (return HTTP 403). Supports exact names (`ads.example.com`) or leading-dot suffixes (`.doubleclick.net`). | +| `bypass_hosts` | `["localhost", ".local", ".lan", ".home.arpa"]` | Hosts that go direct (no MITM, no relay). Useful for LAN resources or sites that break under MITM. | +| `direct_google_exclude` | see[config.example.json](config.example.json) | Google apps that must use the MITM relay path instead of the fast direct tunnel. | +| `hosts` | `{}` | Manual DNS override: map a hostname to a specific IP. | +| `youtube_via_relay` | `false` | Route YouTube (`youtube.com`, `youtu.be`, `youtube-nocookie.com`) through the Apps Script relay instead of the SNI-rewrite path. The SNI-rewrite path uses Google's frontend IP which enforces SafeSearch and can cause **"Video Unavailable"** errors. Setting this to `true` fixes playback at the cost of using more Apps Script executions and slightly higher latency. | +| `exit_node.provider` | `cloudflare` | Selected exit-node backend:`cloudflare`, `deno`, `vps`, or `custom`. | +| `exit_node.url` | `""` | Beginner-friendly single URL for the selected provider. | ### Optional Dependencies Install everything from [`requirements.txt`](requirements.txt). All listed packages are optional β€” the proxy runs with no third-party dependencies in basic modes, but without them you lose features: -| Package | Provides | -|---------|----------| -| `cryptography` | MITM TLS interception (required for `apps_script` mode with HTTPS sites) | -| `h2` | HTTP/2 multiplexing to the Apps Script relay (significantly faster) | -| `brotli` | Decompression of `Content-Encoding: br` responses | -| `zstandard` | Decompression of `Content-Encoding: zstd` responses | +| Package | Provides | +| -------------- | ----------------------------------------------------------------------- | +| `cryptography` | MITM TLS interception (required for`apps_script` mode with HTTPS sites) | +| `h2` | HTTP/2 multiplexing to the Apps Script relay (significantly faster) | +| `brotli` | Decompression of`Content-Encoding: br` responses | +| `zstandard` | Decompression of`Content-Encoding: zstd` responses | ### Load Balancing @@ -380,7 +424,9 @@ To increase speed, deploy `Code.gs` multiple times to different Apps Script proj ] } ``` + > ⚠️ **Note:** If you are using multiple deployments, the auth-keys must be identical. (All deployments must use the same auth-key.) + --- ## Updating the Google Relay @@ -415,6 +461,7 @@ python3 main.py --scan ``` This will: + 1. Probe 27 candidate Google IPs in parallel 2. Measure latency from your network 3. Display results in a table @@ -422,6 +469,7 @@ This will: 5. Exit with exit code 0 if at least one IP is reachable, 1 otherwise **Example output:** + ``` Scanning 27 Google frontend IPs SNI: www.google.com @@ -495,18 +543,19 @@ MasterHttpRelayVPN/ ## Troubleshooting -| Problem | Solution | -|---------|----------| -| "Config not found" | Copy `config.example.json` to `config.json` and fill in your values | -| Browser shows certificate errors | Install the CA certificate (see Step 6 above) | -| Telegram works but browser doesn't load sites | Almost certainly the CA certificate is not installed. Follow Step 6 to install `ca/ca.crt`, then **fully close and reopen your browser** (for Chrome/Edge, make sure no Chrome process is running in the background before reopening). | -| Installed the cert but browser still errors | Chrome and Edge cache certificates β€” you must **completely close** the browser (check Task Manager / system tray) and reopen it for the new cert to take effect. Firefox requires a separate import (see Step 6 Firefox section). | -| "unauthorized" error | Make sure `auth_key` in `config.json` matches `AUTH_KEY` in `Code.gs` exactly | -| Connection timeout | Try a different `google_ip` or check your internet connection | -| Slow browsing | Deploy multiple `Code.gs` copies and use `script_ids` array for load balancing | -| `502 Bad JSON` error | Google returned an unexpected response (HTML instead of JSON). Causes: wrong `script_id`, Apps Script daily quota exhausted, or the deployment wasn't re-created after editing `Code.gs`. Check your `script_id` and create a **new deployment** if you recently changed `Code.gs`. | -| Telegram works on HTTP proxy but not on SOCKS5 | **Expected.** SOCKS5 clients resolve hostnames locally and connect to raw IPs, so Telegram's MTProto-obfuscated bytes reach a blocked IP that we can neither direct-tunnel nor intercept. Configure Telegram as an **HTTP proxy** (`127.0.0.1:8085`) instead β€” it sends hostnames, which the proxy handles via SNI-rewrite through Google. | -| Google and YouTube open but YouTube videos don't play and other sites don't load | The connection to `script.google.com` was not successfully established. This is likely caused by an issue with the deployment of `Code.gs` on Google Apps Script, or the daily execution quota has been exhausted. Re-deploy `Code.gs` with a new deployment and verify your `script_id`, or wait until the quota resets (midnight Pacific Time / 10:30 AM Iran Time). | + +| Problem | Solution | +| -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| "Config not found" | Copy`config.example.json` to `config.json` and fill in your values | +| Browser shows certificate errors | Install the CA certificate (see Step 6 above) | +| Telegram works but browser doesn't load sites | Almost certainly the CA certificate is not installed. Follow Step 6 to install`ca/ca.crt`, then **fully close and reopen your browser** (for Chrome/Edge, make sure no Chrome process is running in the background before reopening). | +| Installed the cert but browser still errors | Chrome and Edge cache certificates β€” you must**completely close** the browser (check Task Manager / system tray) and reopen it for the new cert to take effect. Firefox requires a separate import (see Step 6 Firefox section). | +| "unauthorized" error | Make sure`auth_key` in `config.json` matches `AUTH_KEY` in `Code.gs` exactly | +| Connection timeout | Try a different`google_ip` or check your internet connection | +| Slow browsing | Deploy multiple`Code.gs` copies and use `script_ids` array for load balancing | +| `502 Bad JSON` error | Google returned an unexpected response (HTML instead of JSON). Causes: wrong`script_id`, Apps Script daily quota exhausted, or the deployment wasn't re-created after editing `Code.gs`. Check your `script_id` and create a **new deployment** if you recently changed `Code.gs`. | +| Telegram works on HTTP proxy but not on SOCKS5 | **Expected.** SOCKS5 clients resolve hostnames locally and connect to raw IPs, so Telegram's MTProto-obfuscated bytes reach a blocked IP that we can neither direct-tunnel nor intercept. Configure Telegram as an **HTTP proxy** (`127.0.0.1:8085`) instead β€” it sends hostnames, which the proxy handles via SNI-rewrite through Google. | +| Google and YouTube open but YouTube videos don't play and other sites don't load | The connection to`script.google.com` was not successfully established. This is likely caused by an issue with the deployment of `Code.gs` on Google Apps Script, or the daily execution quota has been exhausted. Re-deploy `Code.gs` with a new deployment and verify your `script_id`, or wait until the quota resets (midnight Pacific Time / 10:30 AM Iran Time). | --- @@ -517,6 +566,7 @@ MasterHttpRelayVPN/ - **Don't share the `ca/` folder** β€” it contains your private certificate key. - Keep `listen_host` as `127.0.0.1` so only your computer can use the proxy. - Every google scripts deployment has limit of 20,000 requests in 24 hours + --- ## Special Thanks diff --git a/apps_script/cloudflare_worker.js b/apps_script/cloudflare_worker.js index db3762c..d4e6614 100644 --- a/apps_script/cloudflare_worker.js +++ b/apps_script/cloudflare_worker.js @@ -1,5 +1,10 @@ // MasterHttpRelay exit node for Cloudflare Workers. // Deploy as HTTP endpoint and set PSK to a strong secret. +// +// For TCP relay (SSH etc.) the cloudflare:sockets API is used. +// No special wrangler.toml flags needed for deployed workers. + +import { connect as cfConnect } from "cloudflare:sockets"; const PSK = "CHANGE_ME_TO_A_STRONG_SECRET"; @@ -44,8 +49,15 @@ function sanitizeHeaders(h) { } export default { - async fetch(req) { + async fetch(req, _env, ctx) { try { + // ── WebSocket TCP relay ────────────────────────────────────────────── + // Upgrade requests bypass the normal HTTP relay path entirely. + // Endpoint: GET /tcp?k=&host=&port= + Upgrade: websocket + if (req.headers.get("upgrade")?.toLowerCase() === "websocket") { + return handleWsTcpRelay(req, ctx); + } + // 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") { @@ -116,3 +128,81 @@ export default { } }, }; + +// ── WebSocket TCP relay ──────────────────────────────────────────────────── +// Accepts a WebSocket upgrade, opens a raw TCP socket to the target host/port +// using the cloudflare:sockets API, and pipes bytes bidirectionally. +// +// WS β†’ TCP uses a TransformStream as a queue so that a single pipeTo() call +// owns the tcpSocket.writable lock β€” no per-message getWriter/releaseLock +// races, and backpressure is handled automatically. +// TCP β†’ WS uses a second pipeTo() into a WritableStream that calls serverWs.send(). +// ctx.waitUntil(Promise.all([...])) keeps the worker alive for both directions. + +async function handleWsTcpRelay(req, ctx) { + const url = new URL(req.url); + + const k = url.searchParams.get("k") ?? ""; + if (!PSK || k !== PSK) { + return new Response("Unauthorized", { status: 401 }); + } + + const host = url.searchParams.get("host") ?? ""; + const port = parseInt(url.searchParams.get("port") ?? "", 10); + + if (!host || !port || port < 1 || port > 65535) { + return new Response("Bad host/port", { status: 400 }); + } + + const { 0: clientWs, 1: serverWs } = new WebSocketPair(); + serverWs.accept(); + + const tcpSocket = cfConnect({ hostname: host, port }); + + // WS β†’ TCP: use a TransformStream as an ordered queue so that + // pipeTo() owns the tcpSocket.writable lock and handles backpressure. + // Per-message getWriter/releaseLock races are avoided entirely. + const { readable: toTcp, writable: toTcpSink } = new TransformStream(); + const toTcpWriter = toTcpSink.getWriter(); + + serverWs.addEventListener("message", ({ data }) => { + const bytes = + data instanceof ArrayBuffer + ? new Uint8Array(data) + : new TextEncoder().encode(String(data)); + toTcpWriter.write(bytes).catch(() => {}); + }); + + serverWs.addEventListener("close", () => { + toTcpWriter.close().catch(() => {}); + tcpSocket.close().catch(() => {}); + }); + + serverWs.addEventListener("error", () => { + toTcpWriter.abort("ws error").catch(() => {}); + tcpSocket.close().catch(() => {}); + }); + + // Drain the queue into the TCP socket. + const wsTcpDone = toTcp.pipeTo(tcpSocket.writable).catch(() => {}); + + // TCP β†’ WS: pipe TCP readable into WebSocket sends. + const tcpWsDone = tcpSocket.readable.pipeTo( + new WritableStream({ + write(chunk) { + if (serverWs.readyState === 1 /* OPEN */) serverWs.send(chunk); + }, + close() { + if (serverWs.readyState === 1) serverWs.close(1000, "TCP closed"); + }, + abort() { + if (serverWs.readyState === 1) serverWs.close(1011, "TCP error"); + }, + }) + ).catch(() => {}); + + // Keep the worker alive for both pump directions. + ctx.waitUntil(Promise.all([wsTcpDone, tcpWsDone])); + + return new Response(null, { status: 101, webSocket: clientWs }); +} diff --git a/apps_script/deno_tcp_relay.ts b/apps_script/deno_tcp_relay.ts new file mode 100644 index 0000000..dd8ebcf --- /dev/null +++ b/apps_script/deno_tcp_relay.ts @@ -0,0 +1,152 @@ +// MasterHttpRelay β€” TCP relay for Deno Deploy. +// +// Accepts WebSocket upgrades and proxies raw TCP using Deno.connect(). +// Deploy this as a SEPARATE Deno Deploy project from the HTTP relay. +// +// Endpoint: +// GET /tcp?k=&host=&port= +// (with Upgrade: websocket header) +// +// Health check: +// GET /health β†’ {"ok": true} +// +// Configuration: +// Change PSK to a strong secret and redeploy. + +declare const Deno: any; + +const PSK = "CHANGE_ME_TO_A_STRONG_SECRET"; + +Deno.serve(async (req: Request): Promise => { + const url = new URL(req.url); + + // ── Health check ────────────────────────────────────────────────────── + if (req.method === "GET" && url.pathname === "/health") { + return Response.json({ ok: true, service: "tcp-relay" }); + } + + // ── Only WebSocket upgrades beyond this point ───────────────────────── + if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") { + return Response.json( + { e: "websocket_required" }, + { status: 426, headers: { "Upgrade": "websocket" } }, + ); + } + + // ── Auth ────────────────────────────────────────────────────────────── + const k = url.searchParams.get("k") ?? ""; + if (!PSK || k !== PSK) { + return new Response("Unauthorized", { status: 401 }); + } + + // ── Target parsing ──────────────────────────────────────────────────── + const host = url.searchParams.get("host") ?? ""; + const portStr = url.searchParams.get("port") ?? ""; + const port = parseInt(portStr, 10); + + if (!host || !port || port < 1 || port > 65535) { + return new Response("Bad host/port", { status: 400 }); + } + + // SSRF guard: block loopback / private ranges (Deno's sandbox is the + // primary barrier, but defense-in-depth is worth the few lines). + if (_isPrivateHost(host)) { + return new Response("Forbidden", { status: 403 }); + } + + // ── WebSocket upgrade ───────────────────────────────────────────────── + const { socket: ws, response } = Deno.upgradeWebSocket(req); + + ws.binaryType = "arraybuffer"; + + let tcpConn: any = null; + let closing = false; + + // onopen: attempt the TCP connection. + ws.onopen = async () => { + try { + tcpConn = await Deno.connect({ hostname: host, port }); + } catch (err) { + closing = true; + ws.close(1011, `TCP connect failed: ${err}`); + return; + } + // Start pumping TCP β†’ WS in the background. + _pumpTcpToWs(tcpConn, ws, () => { closing = true; }); + }; + + // onmessage: WS β†’ TCP. + ws.onmessage = async (event: MessageEvent) => { + if (closing || !tcpConn) return; + const data: Uint8Array = + event.data instanceof ArrayBuffer + ? new Uint8Array(event.data) + : new TextEncoder().encode(event.data as string); + try { + // Deno.Conn.write() may write fewer bytes than requested β€” loop. + let offset = 0; + while (offset < data.length) { + const n = await tcpConn.write(data.subarray(offset)); + offset += n; + } + } catch (_err) { + if (!closing) { + closing = true; + try { ws.close(1011, "TCP write failed"); } catch (_) {} + } + } + }; + + ws.onclose = () => { + closing = true; + if (tcpConn) { + try { tcpConn.close(); } catch (_) {} + tcpConn = null; + } + }; + + ws.onerror = () => { + closing = true; + }; + + return response; +}); + +// Pump bytes from TCP connection into the WebSocket. +// Runs as a fire-and-forget async task (called from onopen). +async function _pumpTcpToWs( + conn: any, + ws: WebSocket, + onClose: () => void, +): Promise { + const buf = new Uint8Array(65536); + try { + while (true) { + const n = await conn.read(buf); + if (n === null) break; // EOF from TCP + if (ws.readyState !== WebSocket.OPEN) break; + ws.send(buf.slice(0, n)); + } + } catch (_err) { + // TCP read error β€” connection reset or timed out. + } finally { + onClose(); + if (ws.readyState === WebSocket.OPEN) { + try { ws.close(1000, "TCP closed"); } catch (_) {} + } + } +} + +function _isPrivateHost(host: string): boolean { + const lower = host.toLowerCase(); + if (lower === "localhost" || lower.endsWith(".localhost")) return true; + if (lower === "::1" || lower.startsWith("127.")) return true; + if (lower.startsWith("10.") || lower.startsWith("192.168.")) return true; + if (lower.startsWith("169.254.")) return true; // link-local + if (lower.startsWith("172.")) { + // 172.16.0.0/12 + const second = parseInt(lower.split(".")[1] ?? "0", 10); + if (second >= 16 && second <= 31) return true; + } + return false; +} diff --git a/config.example.json b/config.example.json index 3539dec..58bdaec 100644 --- a/config.example.json +++ b/config.example.json @@ -95,5 +95,14 @@ "example.com", "example.org" ] + }, + "tcp_ws_relay": { + "enabled": false, + "ws_url": "wss://your-tcp-relay.deno.dev/tcp", + "auth_key": "CHANGE_ME_TO_A_STRONG_SECRET", + "front_domain": null, + "front_ip": null, + "connect_timeout": 15, + "ping_interval": 20 } } diff --git a/src/core/constants.py b/src/core/constants.py index 766fde2..3fe28f9 100644 --- a/src/core/constants.py +++ b/src/core/constants.py @@ -225,3 +225,11 @@ "cookie", "authorization", "proxy-authorization", "range", "if-none-match", "if-modified-since", "cache-control", "pragma", ) + + +# ── WebSocket TCP relay ─────────────────────────────────────────────────── +# Used for non-HTTP port tunneling (SSH, IMAP, SMTP, etc.) through a +# WebSocket-capable edge relay (Deno Deploy or Cloudflare Workers). +WS_TCP_RELAY_CONNECT_TIMEOUT = 15.0 # seconds to establish WS connection +WS_TCP_RELAY_PING_INTERVAL = 20.0 # seconds between WS keepalive pings +WS_TCP_RELAY_READ_CHUNK = 65536 # bytes per asyncio read call diff --git a/src/proxy/proxy_server.py b/src/proxy/proxy_server.py index 28c0bae..406421b 100644 --- a/src/proxy/proxy_server.py +++ b/src/proxy/proxy_server.py @@ -35,8 +35,16 @@ TCP_CONNECT_TIMEOUT, TRACE_HOST_SUFFIXES, UNCACHEABLE_HEADER_NAMES, + WS_TCP_RELAY_READ_CHUNK, ) from relay.domain_fronter import DomainFronter + +try: + from relay.ws_tcp_tunnel import WSTcpTunnel + _WS_TCP_TUNNEL_AVAILABLE = True +except ImportError: + WSTcpTunnel = None # type: ignore[assignment,misc] + _WS_TCP_TUNNEL_AVAILABLE = False from .socks5 import negotiate_socks5 from .proxy_support import ( ResponseCache, @@ -87,6 +95,33 @@ def __init__(self, config: dict): self.fronter = DomainFronter(config) self.mitm = None self._cache = ResponseCache(max_mb=CACHE_MAX_MB) + + # ── WebSocket TCP relay (non-HTTP port tunneling) ─────────────────── + _ws_cfg = config.get("tcp_ws_relay") or {} + self._ws_relay_enabled: bool = ( + bool(_ws_cfg.get("enabled", False)) and _WS_TCP_TUNNEL_AVAILABLE + ) + self._ws_relay_url: str = str(_ws_cfg.get("ws_url") or "") + self._ws_relay_auth_key: str = str( + _ws_cfg.get("auth_key") or config.get("auth_key", "") + ) + self._ws_relay_front_domain: str | None = _ws_cfg.get("front_domain") or None + self._ws_relay_front_ip: str | None = _ws_cfg.get("front_ip") or None + self._ws_relay_connect_timeout: float = float( + _ws_cfg.get("connect_timeout", 15.0) + ) + self._ws_relay_ping_interval: float = float( + _ws_cfg.get("ping_interval", 20.0) + ) + if self._ws_relay_enabled and not self._ws_relay_url: + log.warning("tcp_ws_relay.enabled=true but ws_url not set β€” disabling") + self._ws_relay_enabled = False + if self._ws_relay_enabled: + log.info( + "WS TCP relay enabled β†’ %s (front=%s)", + self._ws_relay_url, + self._ws_relay_front_domain or "none", + ) self._direct_fail_until: dict[str, float] = {} self._servers: list[asyncio.base_events.Server] = [] self._client_tasks: set[asyncio.Task] = set() @@ -454,7 +489,12 @@ async def _handle_target_tunnel(self, host: str, port: int, return self._remember_direct_failure(host, ttl=300) if port not in (80, 443): - log.warning("Direct tunnel failed for %s:%d", host, port) + if self._ws_relay_enabled: + log.info("WS TCP relay β†’ %s:%d (IP literal, direct failed)", + host, port) + await self._do_ws_tcp_tunnel(host, port, reader, writer) + else: + log.warning("Direct tunnel failed for %s:%d", host, port) return log.warning( "Direct tunnel fallback β†’ %s:%d (switching to relay)", @@ -465,6 +505,14 @@ async def _handle_target_tunnel(self, host: str, port: int, "Relay fallback β†’ %s:%d (direct temporarily disabled)", host, port, ) + if port not in (80, 443): + if self._ws_relay_enabled: + log.info("WS TCP relay β†’ %s:%d (IP literal, direct disabled)", + host, port) + await self._do_ws_tcp_tunnel(host, port, reader, writer) + else: + log.warning("Direct tunnel disabled for %s:%d", host, port) + return if port == 443: await self._do_mitm_connect(host, port, reader, writer) elif port == 80: @@ -505,12 +553,24 @@ async def _handle_target_tunnel(self, host: str, port: int, elif port == 80: await self._do_plain_http_tunnel(host, port, reader, writer) else: - # Non-HTTP port (e.g. mtalk:5228 XMPP, IMAP, SMTP, SSH) β€” - # payload isn't HTTP, so we can't relay or MITM. Tunnel bytes. - log.info("Direct tunnel β†’ %s:%d (non-HTTP port)", host, port) - ok = await self._do_direct_tunnel(host, port, reader, writer) - if not ok: - log.warning("Direct tunnel failed for %s:%d", host, port) + # Non-HTTP port (e.g. XMPP, IMAP, SMTP, SSH) β€” payload isn't HTTP. + # Use the WebSocket TCP relay when configured; fall back to direct. + if self._ws_relay_enabled: + log.info("WS TCP relay β†’ %s:%d (non-HTTP port)", host, port) + ok = await self._do_ws_tcp_tunnel(host, port, reader, writer) + if not ok: + log.warning( + "WS TCP relay failed for %s:%d β€” falling back to direct", + host, port, + ) + direct_ok = await self._do_direct_tunnel(host, port, reader, writer) + if not direct_ok: + log.warning("Direct tunnel also failed for %s:%d", host, port) + else: + log.info("Direct tunnel β†’ %s:%d (non-HTTP port)", host, port) + ok = await self._do_direct_tunnel(host, port, reader, writer) + if not ok: + log.warning("Direct tunnel failed for %s:%d", host, port) # ── Hosts override (fake DNS) ───────────────────────────────── @@ -748,6 +808,73 @@ async def pipe(src, dst, label): ) return True + # ── WebSocket TCP relay tunnel ──────────────────────────────── + + async def _do_ws_tcp_tunnel(self, host: str, port: int, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter) -> bool: + """Pipe raw TCP through a WebSocket relay to bypass censorship. + + Returns True when the tunnel completes normally, or False if the + WebSocket connection could not be established (caller may fall back + to a direct tunnel attempt). + """ + tunnel = WSTcpTunnel( + ws_url=self._ws_relay_url, + auth_key=self._ws_relay_auth_key, + target_host=host, + target_port=port, + front_ip=self._ws_relay_front_ip, + front_domain=self._ws_relay_front_domain, + verify_ssl=self.fronter.verify_ssl, + connect_timeout=self._ws_relay_connect_timeout, + ping_interval=self._ws_relay_ping_interval, + ) + try: + await asyncio.wait_for( + tunnel.connect(), + timeout=self._ws_relay_connect_timeout, + ) + except Exception as e: + log.error("WS TCP relay connect failed (%s:%d): %s", host, port, e) + return False + + async def client_to_relay(): + try: + while True: + data = await reader.read(WS_TCP_RELAY_READ_CHUNK) + if not data: + break + await tunnel.send(data) + except (ConnectionError, asyncio.CancelledError): + pass + except Exception as e: + log.debug("WS pipe clientβ†’relay ended (%s:%d): %s", host, port, e) + finally: + await tunnel.close() + + async def relay_to_client(): + try: + while True: + data = await tunnel.recv() + if not data: + break + writer.write(data) + await writer.drain() + except (ConnectionError, asyncio.CancelledError): + pass + except Exception as e: + log.debug("WS pipe relayβ†’client ended (%s:%d): %s", host, port, e) + finally: + try: + if not writer.is_closing(): + writer.close() + except Exception: + pass + + await asyncio.gather(client_to_relay(), relay_to_client()) + return True + # ── SNI-rewrite tunnel ──────────────────────────────────────── async def _do_sni_rewrite_tunnel(self, host: str, port: int, reader, writer, diff --git a/src/relay/ws_tcp_tunnel.py b/src/relay/ws_tcp_tunnel.py new file mode 100644 index 0000000..abe593b --- /dev/null +++ b/src/relay/ws_tcp_tunnel.py @@ -0,0 +1,374 @@ +""" +WebSocket TCP tunnel client (RFC 6455, stdlib only). + +Establishes a domain-fronted TLS connection to a WebSocket relay endpoint, +upgrades to WebSocket, then exposes an async send/recv interface for +bidirectional raw TCP proxying. + +No external dependencies β€” uses only asyncio, ssl, hashlib, base64, +struct, os, urllib.parse from stdlib. +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import logging +import os +import socket +import ssl +import struct +from urllib.parse import urlparse + +try: + import certifi +except Exception: + certifi = None + +from core.constants import ( + WS_TCP_RELAY_CONNECT_TIMEOUT, + WS_TCP_RELAY_PING_INTERVAL, + WS_TCP_RELAY_READ_CHUNK, +) + +log = logging.getLogger("WSTcpTunnel") + +# RFC 6455 magic GUID used in Sec-WebSocket-Accept computation. +_WS_GUID = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +# WebSocket opcodes we care about. +_OP_BINARY = 0x2 +_OP_TEXT = 0x1 +_OP_CLOSE = 0x8 +_OP_PING = 0x9 +_OP_PONG = 0xA + + +# ── RFC 6455 frame codec ───────────────────────────────────────────────── + +def _encode_frame(opcode: int, payload: bytes, mask: bool = True) -> bytes: + """Encode a single complete (FIN=1) WebSocket frame.""" + plen = len(payload) + if plen <= 125: + length_byte = plen + ext = b"" + elif plen <= 65535: + length_byte = 126 + ext = struct.pack("!H", plen) + else: + length_byte = 127 + ext = struct.pack("!Q", plen) + + fin_opcode = 0x80 | (opcode & 0x0F) # FIN=1 + + if mask: + length_byte |= 0x80 + mask_key = os.urandom(4) + # XOR in 4-byte strides for speed; handle remainder separately. + n, r = divmod(plen, 4) + chunks = bytearray(n * 4) + mk = struct.unpack_from("!I", mask_key)[0] + for i in range(n): + word = struct.unpack_from("!I", payload, i * 4)[0] + struct.pack_into("!I", chunks, i * 4, word ^ mk) + tail = bytes(payload[n * 4 + j] ^ mask_key[j] for j in range(r)) + return bytes([fin_opcode, length_byte]) + ext + mask_key + bytes(chunks) + tail + else: + return bytes([fin_opcode, length_byte]) + ext + payload + + +async def _read_frame(reader: asyncio.StreamReader) -> tuple[int, bytes]: + """Read exactly one WebSocket frame from `reader`. + + Returns (opcode, payload). Raises asyncio.IncompleteReadError or + ConnectionError on EOF / closed connection. + """ + header = await reader.readexactly(2) + opcode = header[0] & 0x0F + masked = bool(header[1] & 0x80) + plen = header[1] & 0x7F + + if plen == 126: + ext = await reader.readexactly(2) + plen = struct.unpack("!H", ext)[0] + elif plen == 127: + ext = await reader.readexactly(8) + plen = struct.unpack("!Q", ext)[0] + + if masked: + mask_key = await reader.readexactly(4) + raw = await reader.readexactly(plen) + payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(raw)) + else: + payload = await reader.readexactly(plen) + + return opcode, payload + + +# ── WSTcpTunnel ────────────────────────────────────────────────────────── + +class WSTcpTunnel: + """Async WebSocket tunnel to a remote TCP endpoint via a relay. + + The relay is expected to accept a WebSocket upgrade at: + wss:///tcp?k=&host=&port= + + It then connects to : over TCP and forwards bytes + bidirectionally through the WebSocket. + + Domain fronting: if `front_ip` and/or `front_domain` are set, the TCP + connection goes to `front_ip` and the TLS SNI is set to `front_domain`, + while the HTTP Host header still uses the relay's actual hostname so the + CDN routes the request correctly. + + Usage:: + + tunnel = WSTcpTunnel( + ws_url="wss://my-relay.deno.dev/tcp", + auth_key="secret", + target_host="ssh.example.com", + target_port=22, + ) + await tunnel.connect() + await tunnel.send(b"...") + data = await tunnel.recv() # b"" on close + await tunnel.close() + """ + + def __init__( + self, + ws_url: str, + auth_key: str, + target_host: str, + target_port: int, + *, + front_ip: str | None = None, + front_domain: str | None = None, + verify_ssl: bool = True, + connect_timeout: float = WS_TCP_RELAY_CONNECT_TIMEOUT, + ping_interval: float = WS_TCP_RELAY_PING_INTERVAL, + ): + self._ws_url = ws_url + self._auth_key = auth_key + self._target_host = target_host + self._target_port = target_port + self._front_ip = front_ip + self._front_domain = front_domain + self._verify_ssl = verify_ssl + self._connect_timeout = connect_timeout + self._ping_interval = ping_interval + + self._reader: asyncio.StreamReader | None = None + self._writer: asyncio.StreamWriter | None = None + self._closed = False + self._write_lock = asyncio.Lock() + self._ping_task: asyncio.Task | None = None + + # ── Public API ─────────────────────────────────────────────────── + + async def connect(self) -> None: + """Establish the WebSocket connection. Raises on failure.""" + parsed = urlparse(self._ws_url) + scheme = parsed.scheme.lower() # "wss" or "ws" + url_host = parsed.hostname or "" + url_port = parsed.port or (443 if scheme == "wss" else 80) + path = parsed.path or "/" + if parsed.query: + path += "?" + parsed.query + + # Append relay params (auth + target) to the path. + sep = "&" if "?" in path else "?" + path = ( + f"{path}{sep}k={_pct_encode(self._auth_key)}" + f"&host={_pct_encode(self._target_host)}" + f"&port={self._target_port}" + ) + + # Connection target: front_ip overrides DNS if set. + connect_addr = self._front_ip or url_host + connect_port = url_port + + # TLS SNI: front_domain overrides if set (domain fronting). + sni_host = self._front_domain or url_host + + use_tls = (scheme == "wss") + ssl_ctx: ssl.SSLContext | None = None + if use_tls: + ssl_ctx = ssl.create_default_context() + if certifi is not None: + try: + ssl_ctx.load_verify_locations(cafile=certifi.where()) + except Exception: + pass + if not self._verify_ssl: + ssl_ctx.check_hostname = False + ssl_ctx.verify_mode = ssl.CERT_NONE + + # asyncio.open_connection handles DNS resolution automatically. + # server_hostname controls TLS SNI independently of the connect address, + # which is exactly what domain fronting needs (connect to front_ip but + # present sni_host in the TLS handshake). + try: + self._reader, self._writer = await asyncio.wait_for( + asyncio.open_connection( + connect_addr, connect_port, + ssl=ssl_ctx, + server_hostname=sni_host if use_tls else None, + ), + timeout=self._connect_timeout, + ) + except Exception: + raise + + # TCP_NODELAY after connection β€” important for SSH interactive latency. + try: + sock = self._writer.get_extra_info("socket") + if sock: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except Exception: + pass + + # HTTP/1.1 WebSocket upgrade handshake. + ws_key = base64.b64encode(os.urandom(16)).decode() + request = ( + f"GET {path} HTTP/1.1\r\n" + f"Host: {url_host}\r\n" + f"Upgrade: websocket\r\n" + f"Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {ws_key}\r\n" + f"Sec-WebSocket-Version: 13\r\n" + f"\r\n" + ) + self._writer.write(request.encode()) + await self._writer.drain() + + # Read and validate the 101 response. + response_bytes = await asyncio.wait_for( + self._reader.readuntil(b"\r\n\r\n"), + timeout=self._connect_timeout, + ) + response_text = response_bytes.decode("utf-8", errors="replace") + if "101" not in response_text.split("\r\n", 1)[0]: + raise ConnectionError( + f"WebSocket upgrade failed: {response_text.split(chr(10), 1)[0].strip()}" + ) + + # Verify Sec-WebSocket-Accept. + expected_accept = base64.b64encode( + hashlib.sha1((ws_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest() + ).decode() + if expected_accept.lower() not in response_text.lower(): + raise ConnectionError("Sec-WebSocket-Accept mismatch") + + log.debug("WS tunnel connected β†’ %s:%d via %s", + self._target_host, self._target_port, self._ws_url) + + self._ping_task = asyncio.create_task(self._keepalive_loop()) + + async def send(self, data: bytes) -> None: + """Send raw bytes as a binary WebSocket frame.""" + if self._closed: + raise ConnectionError("WSTcpTunnel is closed") + frame = _encode_frame(_OP_BINARY, data, mask=True) + async with self._write_lock: + if self._closed: + raise ConnectionError("WSTcpTunnel is closed") + self._writer.write(frame) + await self._writer.drain() + + async def recv(self) -> bytes: + """Receive the next chunk of raw bytes from the relay. + + Returns b"" when the connection is closed (either side). + """ + while True: + try: + opcode, payload = await _read_frame(self._reader) + except (asyncio.IncompleteReadError, ConnectionError, OSError): + self._closed = True + return b"" + + if opcode in (_OP_BINARY, _OP_TEXT): + return payload + + if opcode == _OP_PING: + # Respond to server ping. + try: + async with self._write_lock: + self._writer.write(_encode_frame(_OP_PONG, payload, mask=True)) + await self._writer.drain() + except Exception: + pass + continue + + if opcode == _OP_PONG: + # Keepalive pong from server β€” just discard. + continue + + if opcode == _OP_CLOSE: + # Echo close frame and signal EOF. + try: + async with self._write_lock: + self._writer.write(_encode_frame(_OP_CLOSE, b"", mask=True)) + await self._writer.drain() + except Exception: + pass + self._closed = True + return b"" + + # Unknown opcode β€” treat as close. + self._closed = True + return b"" + + async def close(self) -> None: + """Send a WS close frame and close the underlying TCP connection.""" + if self._closed: + return + self._closed = True + if self._ping_task is not None: + self._ping_task.cancel() + try: + await self._ping_task + except (asyncio.CancelledError, Exception): + pass + try: + async with self._write_lock: + self._writer.write(_encode_frame(_OP_CLOSE, b"", mask=True)) + await self._writer.drain() + except Exception: + pass + try: + self._writer.close() + except Exception: + pass + + # ── Internal ───────────────────────────────────────────────────── + + async def _keepalive_loop(self) -> None: + try: + while not self._closed: + await asyncio.sleep(self._ping_interval) + if self._closed: + break + try: + async with self._write_lock: + if not self._closed: + self._writer.write( + _encode_frame(_OP_PING, b"\x00" * 4, mask=True) + ) + await self._writer.drain() + except Exception: + break + except asyncio.CancelledError: + pass + + +def _pct_encode(value: str) -> str: + """Percent-encode a string for use in a URL query parameter.""" + safe = ( + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "0123456789-._~" + ) + return "".join(c if c in safe else f"%{ord(c):02X}" for c in value)