Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 100 additions & 50 deletions README.md

Large diffs are not rendered by default.

92 changes: 91 additions & 1 deletion apps_script/cloudflare_worker.js
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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=<psk>&host=<host>&port=<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") {
Expand Down Expand Up @@ -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 });
}
152 changes: 152 additions & 0 deletions apps_script/deno_tcp_relay.ts
Original file line number Diff line number Diff line change
@@ -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=<auth_key>&host=<target_host>&port=<target_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<Response> => {
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<void> {
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;
}
9 changes: 9 additions & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
8 changes: 8 additions & 0 deletions src/core/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading