From 3539b21f49717545247c4ac947151b239612a600 Mon Sep 17 00:00:00 2001 From: Jaydenha09 Date: Thu, 25 Sep 2025 10:24:17 +0800 Subject: [PATCH] feat: introduce 3-tier architecture with relay and exit nodes --- BUILD.md | 115 +++++++----- package.json | 29 ++-- proxy-exit.js | 426 +++++++++++++++++++++++++++++++++++++++++++++ proxy-server.js | 451 +++++++++++------------------------------------- 4 files changed, 620 insertions(+), 401 deletions(-) create mode 100644 proxy-exit.js diff --git a/BUILD.md b/BUILD.md index 0c7a65e..f840703 100644 --- a/BUILD.md +++ b/BUILD.md @@ -1,11 +1,12 @@ # PSK-Proxy-Tunnel Build & Usage Guide -This guide explains how to build single executable binaries and how to run the TLS-PSK tunnel with a local SOCKS5 proxy client (supporting TCP CONNECT and UDP ASSOCIATE). +This guide explains how to build single executable binaries and how to run the TLS-PSK tunnel with a three-tier architecture: a local SOCKS5 proxy client, a relay node, and an exit node. Key changes: -- Local proxy is now SOCKS5 (replaces the previous HTTP proxy). -- The tunnel supports multiplexed TCP and UDP relaying. -- Existing frame protocol extended with UDP_* frames for SOCKS5 UDP ASSOCIATE. +- The architecture is now Client -> Relay -> Exit. +- `proxy-server.js` is now a relay node (`psk-proxy-relay`). +- A new `proxy-exit.js` script acts as the exit node (`psk-proxy-exit`). +- The client connects to the relay, and the relay connects to the exit node. ## Prerequisites @@ -85,89 +86,119 @@ Executables are created in `dist/`: ``` dist/ -├── psk-proxy-server-macos ├── psk-proxy-client-macos -├── psk-proxy-server-linux +├── psk-proxy-relay-macos +├── psk-proxy-exit-macos ├── psk-proxy-client-linux -├── psk-proxy-server-windows.exe -└── psk-proxy-client-windows.exe +├── psk-proxy-relay-linux +├── psk-proxy-exit-linux +├── psk-proxy-client-windows.exe +├── psk-proxy-relay-windows.exe +└── psk-proxy-exit-windows.exe ``` -## Running the Server and Client +## Running the Servers and Client -The PSK (pre-shared key) file must contain a hex-encoded key string used by both sides. Example (256-bit key): -``` -0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef -``` +The PSK (pre-shared key) file must contain a hex-encoded key string used by all components. -### Server (Out-Node) +### 1. Exit Node -- Listens for a single TLS-PSK tunnel connection from the client. +- Listens for a single TLS-PSK tunnel connection from the relay. - Performs outbound TCP connects and UDP sends on behalf of the client. macOS/Linux: ```bash -./dist/psk-proxy-server-macos \ - --tunnel-port 8443 \ +./dist/psk-proxy-exit-macos \ + --relay-port 9000 \ --host 0.0.0.0 \ --psk-file /path/to/psk.hex ``` Windows: ```cmd -.\dist\psk-proxy-server-windows.exe ^ - --tunnel-port 8443 ^ +.\dist\psk-proxy-exit-windows.exe ^ + --relay-port 9000 ^ --host 0.0.0.0 ^ --psk-file C:\path\to\psk.hex ``` Required options: -- `--tunnel-port `: TLS-PSK tunnel port -- `--host `: Bind host (e.g., 0.0.0.0) -- `--psk-file `: File containing hex PSK +- `--relay-port `: Port for the relay to connect to. +- `--host `: Bind host (e.g., 0.0.0.0). +- `--psk-file `: File containing hex PSK. -Optional: -- `--connect-timeout `: Outbound TCP connect timeout (default 10000) +### 2. Relay Node -### Client (Local SOCKS5 Proxy) +- Listens for the client and connects to the exit node. +- Relays traffic between the client and the exit node. -- Runs a local SOCKS5 proxy (TCP CONNECT and UDP ASSOCIATE). -- Multiplexes many local connections over one TLS-PSK tunnel to the server. +macOS/Linux: +```bash +./dist/psk-proxy-relay-macos \ + --tunnel-port 8443 \ + --host 0.0.0.0 \ + --psk-file /path/to/psk.hex \ + --exit-host exit.node.com \ + --exit-port 9000 \ + --exit-identity relay1 +``` + +Windows: +```cmd +.\dist\psk-proxy-relay-windows.exe ^ + --tunnel-port 8443 ^ + --host 0.0.0.0 ^ + --psk-file C:\path\to\psk.hex ^ + --exit-host exit.node.com ^ + --exit-port 9000 ^ + --exit-identity relay1 +``` + +Required options: +- `--tunnel-port `: Port for the client to connect to. +- `--host `: Bind host. +- `--psk-file `: File containing hex PSK. +- `--exit-host `: Exit node host. +- `--exit-port `: Exit node port. +- `--exit-identity `: Identity for the relay when connecting to the exit node. + +### 3. Client (Local SOCKS5 Proxy) + +- Runs a local SOCKS5 proxy. +- Connects to the relay node. macOS/Linux: ```bash ./dist/psk-proxy-client-macos \ - --server-host server.example.com \ + --server-host relay.node.com \ --server-port 8443 \ --psk-file /path/to/psk.hex \ --identity client1 \ - --socks-port 1080 \ - --bind-host 127.0.0.1 + --socks-port 1080 ``` Windows: ```cmd .\dist\psk-proxy-client-windows.exe ^ - --server-host server.example.com ^ + --server-host relay.node.com ^ --server-port 8443 ^ --psk-file C:\path\to\psk.hex ^ --identity client1 ^ - --socks-port 1080 ^ - --bind-host 127.0.0.1 + --socks-port 1080 ``` Required options: -- `--server-host `: Remote out-node address -- `--server-port `: Remote out-node port -- `--psk-file `: File containing hex PSK -- `--identity `: Identity string (logged on server) -- `--socks-port `: Local SOCKS5 proxy port +- `--server-host `: Relay node address. +- `--server-port `: Relay node port. +- `--psk-file `: File containing hex PSK. +- `--identity `: Identity string (logged on relay). +- `--socks-port `: Local SOCKS5 proxy port. Optional: -- `--bind-host `: Local bind host (default `127.0.0.1`) -- `--connect-timeout `: Waiting time for OPEN/UDP_OPEN result (default 10000) -- `--idle-timeout `: Idle timeout for TCP sockets (default 60000, 0=disabled) -- `--udp-idle-timeout `: Idle timeout for UDP association (default 60000, 0=disabled) +- `--bind-host `: Local bind host (default `127.0.0.1`). +- `--connect-timeout `: Timeout for connection setup (default 10000). +- `--idle-timeout `: Idle timeout for TCP sockets (default 60000). +- `--udp-idle-timeout `: Idle timeout for UDP association (default 60000). ## Protocol Summary diff --git a/package.json b/package.json index dad7669..eb2e8f6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "psk-proxy-tunnel", - "version": "1.0.0", - "description": "TLS-PSK multiplexed TCP+UDP tunnel server and local SOCKS5 proxy client (CONNECT and UDP ASSOCIATE) for secure NAT traversal and protocol forwarding", + "version": "1.1.0", + "description": "TLS-PSK multiplexed TCP+UDP tunnel with a 3-tier architecture (client, relay, exit) for secure NAT traversal.", "main": "proxy-client.js", "type": "commonjs", "scripts": { @@ -9,17 +9,21 @@ "clean": "node -e \"try{require('fs').rmSync('dist',{recursive:true,force:true})}catch(e){}\"", "prebuild": "npm run clean && node -e \"require('fs').mkdirSync('dist',{recursive:true})\"", "build": "npm run build:macos && npm run build:linux && npm run build:windows", - "build:macos": "npm run build:server:macos && npm run build:client:macos", - "build:server:macos": "pkg proxy-server.js --targets node18-macos-x64 --output dist/psk-proxy-server-macos", + "build:macos": "npm run build:relay:macos && npm run build:client:macos && npm run build:exit:macos", + "build:relay:macos": "pkg proxy-server.js --targets node18-macos-x64 --output dist/psk-proxy-relay-macos", "build:client:macos": "pkg proxy-client.js --targets node18-macos-x64 --output dist/psk-proxy-client-macos", - "build:linux": "npm run build:server:linux && npm run build:client:linux", - "build:server:linux": "pkg proxy-server.js --targets node18-linux-x64 --output dist/psk-proxy-server-linux", + "build:exit:macos": "pkg proxy-exit.js --targets node18-macos-x64 --output dist/psk-proxy-exit-macos", + "build:linux": "npm run build:relay:linux && npm run build:client:linux && npm run build:exit:linux", + "build:relay:linux": "pkg proxy-server.js --targets node18-linux-x64 --output dist/psk-proxy-relay-linux", "build:client:linux": "pkg proxy-client.js --targets node18-linux-x64 --output dist/psk-proxy-client-linux", - "build:windows": "npm run build:server:windows && npm run build:client:windows", - "build:server:windows": "pkg proxy-server.js --targets node18-win-x64 --output dist/psk-proxy-server-windows.exe", + "build:exit:linux": "pkg proxy-exit.js --targets node18-linux-x64 --output dist/psk-proxy-exit-linux", + "build:windows": "npm run build:relay:windows && npm run build:client:windows && npm run build:exit:windows", + "build:relay:windows": "pkg proxy-server.js --targets node18-win-x64 --output dist/psk-proxy-relay-windows.exe", "build:client:windows": "pkg proxy-client.js --targets node18-win-x64 --output dist/psk-proxy-client-windows.exe", - "start:server": "node proxy-server.js", - "start:client": "node proxy-client.js" + "build:exit:windows": "pkg proxy-exit.js --targets node18-win-x64 --output dist/psk-proxy-exit-windows.exe", + "start:relay": "node proxy-server.js", + "start:client": "node proxy-client.js", + "start:exit": "node proxy-exit.js" }, "repository": { "type": "git", @@ -36,8 +40,9 @@ "node": ">=18.0.0" }, "bin": { - "psk-proxy-server": "./proxy-server.js", - "psk-proxy-client": "./proxy-client.js" + "psk-proxy-relay": "./proxy-server.js", + "psk-proxy-client": "./proxy-client.js", + "psk-proxy-exit": "./proxy-exit.js" }, "pkg": { "assets": [], diff --git a/proxy-exit.js b/proxy-exit.js new file mode 100644 index 0000000..e13eede --- /dev/null +++ b/proxy-exit.js @@ -0,0 +1,426 @@ +#!/usr/bin/env node + +/** + * PSK Proxy Exit-Node (Server) + * + * Listens for a TLS-PSK tunnel connection from the proxy relay. + * Receives OPEN(host,port) to create outbound TCP connections to remote servers, + * then forwards DATA/CLOSE frames bidirectionally. + * + * Also supports UDP relaying for SOCKS5 UDP ASSOCIATE via new UDP_* frames. + */ + +const net = require('net'); +const tls = require('tls'); +const fs = require('fs'); +const dgram = require('dgram'); +const { program } = require('commander'); + +program + .requiredOption('-P, --relay-port ', 'Port for proxy relay TLS-PSK tunnel connections') + .requiredOption('-H, --host ', 'Host to bind to (e.g., 0.0.0.0)') + .requiredOption('--psk-file ', 'Path to PSK key file') + .requiredOption('--identity ', 'Expected PSK identity from relay') + .option('--connect-timeout ', 'Timeout for outbound TCP connect (ms)', '10000') + .parse(); + +const options = program.opts(); + +let pskKey; +try { + pskKey = fs.readFileSync(options.pskFile, 'utf8').trim(); +} catch (error) { + console.error(`Error reading PSK file: ${error.message}`); + process.exit(1); +} + +const OUT_CONNECT_TIMEOUT = parseInt(options.connectTimeout, 10) || 10000; + +// Message Types +const MSG_TYPES = { + DATA: 2, + CLOSE: 3, + OPEN: 4, + OPEN_RESULT: 5, + + UDP_OPEN: 6, + UDP_OPEN_RESULT: 7, + UDP_SEND: 8, + UDP_RECV: 9, + UDP_CLOSE: 10, +}; + +function writeMessage(socket, type, connectionId, data = Buffer.alloc(0)) { + if (!socket || socket.destroyed) return true; + const buf = Buffer.allocUnsafe(9 + data.length); + buf.writeUInt8(type, 0); + buf.writeUInt32BE(connectionId >>> 0, 1); + buf.writeUInt32BE(data.length >>> 0, 5); + if (data.length > 0) data.copy(buf, 9); + return socket.write(buf); +} + +function createMessageReader() { + let buffer = Buffer.alloc(0); + let expectedLength = 9; + let currentMessage = null; + + return function onData(data, callback) { + buffer = Buffer.concat([buffer, data]); + + // Parse as many complete frames as possible + while (buffer.length >= expectedLength) { + if (!currentMessage) { + const type = buffer.readUInt8(0); + const connectionId = buffer.readUInt32BE(1); + const dataLength = buffer.readUInt32BE(5); + currentMessage = { type, connectionId, dataLength }; + expectedLength = 9 + dataLength; + + if (dataLength === 0) { + callback(currentMessage.type, currentMessage.connectionId, Buffer.alloc(0)); + buffer = buffer.subarray(9); + currentMessage = null; + expectedLength = 9; + } + } else { + const messageData = buffer.subarray(9, expectedLength); + callback(currentMessage.type, currentMessage.connectionId, messageData); + buffer = buffer.subarray(expectedLength); + currentMessage = null; + expectedLength = 9; + } + } + }; +} + +function parseOpenPayload(buf) { + if (buf.length < 4) return null; + let offset = 0; + const hostLen = buf.readUInt16BE(offset); offset += 2; + if (buf.length < 2 + hostLen + 2) return null; + const host = buf.subarray(offset, offset + hostLen).toString('utf8'); offset += hostLen; + const port = buf.readUInt16BE(offset); offset += 2; + return { host, port }; +} + +function buildOpenResultPayload(success) { + const b = Buffer.allocUnsafe(1); + b.writeUInt8(success ? 1 : 0, 0); + return b; +} + +function parseUdpPayload(buf) { + // [2B hostLen][host][2B port][2B dataLen][data...] + if (buf.length < 2) return null; + let off = 0; + const hostLen = buf.readUInt16BE(off); off += 2; + if (buf.length < 2 + hostLen + 2 + 2) return null; + const host = buf.subarray(off, off + hostLen).toString('utf8'); off += hostLen; + const port = buf.readUInt16BE(off); off += 2; + const dataLen = buf.readUInt16BE(off); off += 2; + if (buf.length < 2 + hostLen + 2 + 2 + dataLen) return null; + const data = buf.subarray(off, off + dataLen); + return { host, port, data }; +} + +function buildUdpPayload(host, port, data) { + const hostBuf = Buffer.from(host, 'utf8'); + const buf = Buffer.allocUnsafe(2 + hostBuf.length + 2 + 2 + data.length); + let off = 0; + buf.writeUInt16BE(hostBuf.length, off); off += 2; + hostBuf.copy(buf, off); off += hostBuf.length; + buf.writeUInt16BE(port, off); off += 2; + buf.writeUInt16BE(data.length, off); off += 2; + data.copy(buf, off); + return buf; +} + +function pskCallback(socket, identity) { + console.log(`Relay client identity: ${identity}`); + + if (identity !== options.identity) { + console.warn(`PSK identity mismatch. Expected '${options.identity}', got '${identity}'.`); + // Abort the connection by returning a falsy value. + // For TLS 1.2, this should cause the handshake to fail. + return null; + } + + // For TLS 1.2, the callback should return the PSK as a Buffer. + return Buffer.from(pskKey, 'hex'); +} + +let relaySocket = null; +const upstreamConns = new Map(); // connectionId -> net.Socket + +// UDP association mapping: connectionId -> { udp4?: dgram.Socket, udp6?: dgram.Socket } +const udpAssoc = new Map(); + +// Framing and fairness controls +const FRAME_MAX = 16 * 1024; // 16KiB frames to improve interleaving +const BYTES_PER_TICK = 64 * 1024; // Limit per processing burst to yield event loop +// Per-connection TX queues for upstream->relay direction +const txQueues = new Map(); // connectionId -> { queue: Buffer[], sending: boolean } + +function processTxQueue(connectionId, state) { + if (!relaySocket || relaySocket.destroyed) { + // Drop queued data if relay is gone + state.queue = []; + state.sending = false; + return; + } + if (state.sending) return; + state.sending = true; + + let bytesThisTick = 0; + + const run = () => { + if (!relaySocket || relaySocket.destroyed) { + state.queue = []; + state.sending = false; + return; + } + while (state.queue.length) { + const buf = state.queue[0]; + let offset = buf._offset || 0; + + while (offset < buf.length) { + const end = Math.min(offset + FRAME_MAX, buf.length); + const slice = buf.subarray(offset, end); + const ok = writeMessage(relaySocket, MSG_TYPES.DATA, connectionId, slice); + if (!ok) { + // Backpressure: remember progress and resume on drain + buf._offset = end; + relaySocket.once('drain', () => { + // Reset flag to allow re-entry + state.sending = false; + run(); + }); + return; + } + offset = end; + bytesThisTick += slice.length; + if (bytesThisTick >= BYTES_PER_TICK) { + // Yield to allow other I/O + buf._offset = offset; + bytesThisTick = 0; + setImmediate(() => { + state.sending = false; + run(); + }); + return; + } + } + + // Finished this buffer + delete buf._offset; + state.queue.shift(); + } + + state.sending = false; + }; + + run(); +} + +function enqueueToRelay(connectionId, data) { + let state = txQueues.get(connectionId); + if (!state) { + state = { queue: [], sending: false }; + txQueues.set(connectionId, state); + } + state.queue.push(data); + // Attempt to process immediately + if (!state.sending) { + state.sending = false; // ensure process can start + processTxQueue(connectionId, state); + } +} + +function closeAllUpstreams() { + for (const [, s] of upstreamConns) { + try { s.destroy(); } catch (_) {} + } + upstreamConns.clear(); +} + +function closeAllUdp() { + for (const [, obj] of udpAssoc) { + try { obj.udp4 && obj.udp4.close(); } catch (_) {} + try { obj.udp6 && obj.udp6.close(); } catch (_) {} + } + udpAssoc.clear(); +} + +function ensureUdpSocketsForAssoc(connectionId) { + if (udpAssoc.has(connectionId)) return udpAssoc.get(connectionId); + const onMessage = (msg, rinfo) => { + // Forward incoming datagrams to client + writeMessage(relaySocket, MSG_TYPES.UDP_RECV, connectionId, buildUdpPayload(rinfo.address, rinfo.port, msg)); + }; + // Create both IPv4 and IPv6 sockets to support all targets + const u4 = dgram.createSocket('udp4'); + const u6 = dgram.createSocket('udp6'); + + u4.on('message', onMessage); + u6.on('message', onMessage); + + u4.on('error', (err) => { /* log but keep running */ console.warn(`UDP4 error (conn ${connectionId}): ${err.message}`); }); + u6.on('error', (err) => { /* log but keep running */ console.warn(`UDP6 error (conn ${connectionId}): ${err.message}`); }); + + // Bind to ephemeral ports to receive replies + try { u4.bind(0); } catch (_) {} + try { u6.bind(0); } catch (_) {} + + const entry = { udp4: u4, udp6: u6 }; + udpAssoc.set(connectionId, entry); + return entry; +} + +const server = tls.createServer( + { + pskCallback, + ciphers: 'PSK-AES256-GCM-SHA384:PSK-AES128-GCM-SHA256', + }, + (socket) => { + console.log('Proxy relay connected'); + relaySocket = socket; + + socket.setNoDelay(true); + socket.setKeepAlive(true, 30000); + + const reader = createMessageReader(); + + socket.on('data', (data) => { + reader(data, (type, connectionId, payload) => { + if (type === MSG_TYPES.OPEN) { + const spec = parseOpenPayload(payload); + if (!spec) { + console.warn(`Invalid OPEN payload for connection ${connectionId}`); + writeMessage(relaySocket, MSG_TYPES.OPEN_RESULT, connectionId, buildOpenResultPayload(false)); + return; + } + + const { host, port } = spec; + // Create outbound TCP connection + const upstream = net.createConnection({ host, port }); + upstream.setNoDelay(true); + upstream.setKeepAlive(true, 30000); + + let connected = false; + const connectTimer = setTimeout(() => { + if (!connected) { + upstream.destroy(new Error('Connect timeout')); + } + }, OUT_CONNECT_TIMEOUT); + + upstream.once('connect', () => { + connected = true; + clearTimeout(connectTimer); + upstreamConns.set(connectionId, upstream); + // Notify open success + writeMessage(relaySocket, MSG_TYPES.OPEN_RESULT, connectionId, buildOpenResultPayload(true)); + }); + + upstream.on('data', (chunk) => { + // Queue data with framing and backpressure-aware sending + enqueueToRelay(connectionId, chunk); + }); + + const cleanup = () => { + clearTimeout(connectTimer); + if (upstreamConns.get(connectionId) === upstream) { + upstreamConns.delete(connectionId); + } + // Drop any pending TX data for this connection + txQueues.delete(connectionId); + writeMessage(relaySocket, MSG_TYPES.CLOSE, connectionId); + }; + + upstream.on('error', (err) => { + if (!connected) { + clearTimeout(connectTimer); + writeMessage(relaySocket, MSG_TYPES.OPEN_RESULT, connectionId, buildOpenResultPayload(false)); + } else { + // Upstream error after established + cleanup(); + } + }); + + upstream.on('close', () => { + cleanup(); + }); + } else if (type === MSG_TYPES.DATA) { + const upstream = upstreamConns.get(connectionId); + if (upstream && !upstream.destroyed) { + upstream.write(payload); + } + } else if (type === MSG_TYPES.CLOSE) { + const upstream = upstreamConns.get(connectionId); + if (upstream) { + upstream.destroy(); + upstreamConns.delete(connectionId); + } + + // UDP handling + } else if (type === MSG_TYPES.UDP_OPEN) { + try { + ensureUdpSocketsForAssoc(connectionId); + writeMessage(relaySocket, MSG_TYPES.UDP_OPEN_RESULT, connectionId, buildOpenResultPayload(true)); + } catch (e) { + console.warn(`Failed to create UDP association for ${connectionId}: ${e.message}`); + writeMessage(relaySocket, MSG_TYPES.UDP_OPEN_RESULT, connectionId, buildOpenResultPayload(false)); + } + } else if (type === MSG_TYPES.UDP_SEND) { + const parsed = parseUdpPayload(payload); + if (!parsed) { + console.warn(`Invalid UDP_SEND payload for ${connectionId}`); + return; + } + const { host, port, data } = parsed; + const entry = udpAssoc.get(connectionId) || ensureUdpSocketsForAssoc(connectionId); + // Choose v6 socket if IPv6 literal detected + const isV6 = host.includes(':'); + const sock = isV6 ? entry.udp6 : entry.udp4; + try { + sock.send(data, port, host); + } catch (e) { + console.warn(`UDP send failed (conn ${connectionId}) to ${host}:${port} - ${e.message}`); + } + } else if (type === MSG_TYPES.UDP_CLOSE) { + const entry = udpAssoc.get(connectionId); + if (entry) { + try { entry.udp4 && entry.udp4.close(); } catch (_) {} + try { entry.udp6 && entry.udp6.close(); } catch (_) {} + udpAssoc.delete(connectionId); + } + } else { + // ignore unknown types + } + }); + }); + + socket.on('close', () => { + console.log('Proxy relay disconnected'); + relaySocket = null; + closeAllUpstreams(); + closeAllUdp(); + }); + + socket.on('error', (err) => { + console.error('Relay socket error:', err.message); + }); + } +); + +server.listen(parseInt(options.relayPort, 10), options.host, () => { + console.log(`PSK Proxy Exit-Node listening on ${options.host}:${options.relayPort}`); +}); + +process.on('SIGINT', () => { + console.log('Shutting down...'); + try { server.close(); } catch (_) {} + closeAllUpstreams(); + closeAllUdp(); + process.exit(0); +}); diff --git a/proxy-server.js b/proxy-server.js index 3c0a38b..e8b1197 100644 --- a/proxy-server.js +++ b/proxy-server.js @@ -1,42 +1,24 @@ #!/usr/bin/env node /** - * PSK Proxy Out-Node (Server) + * PSK Proxy Relay-Node (Server) * * Listens for a TLS-PSK tunnel connection from the proxy client. - * Receives OPEN(host,port) to create outbound TCP connections to remote servers, - * then forwards DATA/CLOSE frames bidirectionally. - * - * Also supports UDP relaying for SOCKS5 UDP ASSOCIATE via new UDP_* frames. - * - * Protocol (over a single TLS socket): - * Header: [1 byte type][4 bytes connection id][4 bytes data length][data...] - * - * Message Types: - * DATA (2): Carry TCP stream data - * CLOSE (3): Close a TCP stream - * OPEN (4): Open TCP stream to host:port, payload = [2B hostLen][host][2B port] - * OPEN_RESULT (5): Result for OPEN, payload = [1B status] (1 = success, 0 = failure) - * - * UDP_OPEN (6): Create a UDP association (bidirectional relay); payload = empty - * UDP_OPEN_RESULT (7): Result for UDP_OPEN, payload = [1B status] (1 = success, 0 = failure) - * UDP_SEND (8): Send one UDP datagram to host:port - * payload = [2B hostLen][host][2B port][2B dataLen][data...] - * UDP_RECV (9): UDP datagram received from remote; same payload format as UDP_SEND - * UDP_CLOSE (10): Close UDP association; payload = empty + * Establishes a single TLS-PSK tunnel connection to an exit node. + * Relays frames between the client and the exit node. */ -const net = require('net'); const tls = require('tls'); const fs = require('fs'); -const dgram = require('dgram'); const { program } = require('commander'); program .requiredOption('-P, --tunnel-port ', 'Port for proxy client TLS-PSK tunnel connections') .requiredOption('-H, --host ', 'Host to bind to (e.g., 0.0.0.0)') - .requiredOption('--psk-file ', 'Path to PSK key file') - .option('--connect-timeout ', 'Timeout for outbound TCP connect (ms)', '10000') + .requiredOption('--psk-file ', 'Path to PSK key file for client and exit connections') + .requiredOption('--exit-host ', 'Exit node host') + .requiredOption('--exit-port ', 'Exit node port') + .requiredOption('--exit-identity ', 'PSK identity for the exit node') .parse(); const options = program.opts(); @@ -49,41 +31,20 @@ try { process.exit(1); } -const OUT_CONNECT_TIMEOUT = parseInt(options.connectTimeout, 10) || 10000; +// Global state +let clientSocket = null; +let exitSocket = null; +let clientReader = null; +let exitReader = null; -// Message Types -const MSG_TYPES = { - DATA: 2, - CLOSE: 3, - OPEN: 4, - OPEN_RESULT: 5, - - UDP_OPEN: 6, - UDP_OPEN_RESULT: 7, - UDP_SEND: 8, - UDP_RECV: 9, - UDP_CLOSE: 10, -}; - -function writeMessage(socket, type, connectionId, data = Buffer.alloc(0)) { - if (!socket || socket.destroyed) return true; - const buf = Buffer.allocUnsafe(9 + data.length); - buf.writeUInt8(type, 0); - buf.writeUInt32BE(connectionId >>> 0, 1); - buf.writeUInt32BE(data.length >>> 0, 5); - if (data.length > 0) data.copy(buf, 9); - return socket.write(buf); -} - -function createMessageReader() { +function createMessageReader(onMessage) { let buffer = Buffer.alloc(0); let expectedLength = 9; let currentMessage = null; - return function onData(data, callback) { + return function onData(data) { buffer = Buffer.concat([buffer, data]); - // Parse as many complete frames as possible while (buffer.length >= expectedLength) { if (!currentMessage) { const type = buffer.readUInt8(0); @@ -93,14 +54,14 @@ function createMessageReader() { expectedLength = 9 + dataLength; if (dataLength === 0) { - callback(currentMessage.type, currentMessage.connectionId, Buffer.alloc(0)); + onMessage(currentMessage.type, currentMessage.connectionId, Buffer.alloc(0)); buffer = buffer.subarray(9); currentMessage = null; expectedLength = 9; } } else { const messageData = buffer.subarray(9, expectedLength); - callback(currentMessage.type, currentMessage.connectionId, messageData); + onMessage(currentMessage.type, currentMessage.connectionId, messageData); buffer = buffer.subarray(expectedLength); currentMessage = null; expectedLength = 9; @@ -109,324 +70,120 @@ function createMessageReader() { }; } -function parseOpenPayload(buf) { - if (buf.length < 4) return null; - let offset = 0; - const hostLen = buf.readUInt16BE(offset); offset += 2; - if (buf.length < 2 + hostLen + 2) return null; - const host = buf.subarray(offset, offset + hostLen).toString('utf8'); offset += hostLen; - const port = buf.readUInt16BE(offset); offset += 2; - return { host, port }; +function writeMessage(socket, type, connectionId, data = Buffer.alloc(0)) { + if (!socket || socket.destroyed) return; + const buf = Buffer.allocUnsafe(9 + data.length); + buf.writeUInt8(type, 0); + buf.writeUInt32BE(connectionId >>> 0, 1); + buf.writeUInt32BE(data.length >>> 0, 5); + if (data.length > 0) data.copy(buf, 9); + socket.write(buf); } -function buildOpenResultPayload(success) { - const b = Buffer.allocUnsafe(1); - b.writeUInt8(success ? 1 : 0, 0); - return b; +function connectToExitNode() { + console.log(`Connecting to exit node ${options.exitHost}:${options.exitPort}...`); + + const pskCb = () => ({ + identity: options.exitIdentity, + psk: Buffer.from(pskKey, 'hex'), + }); + + const sock = tls.connect( + { + host: options.exitHost, + port: parseInt(options.exitPort, 10), + pskCallback: pskCb, + ciphers: 'PSK-AES256-GCM-SHA384:PSK-AES128-GCM-SHA256', + checkServerIdentity: () => undefined, + }, + () => { + console.log('Connected to exit node'); + } + ); + + sock.setNoDelay(true); + sock.setKeepAlive(true, 30000); + + exitSocket = sock; + exitReader = createMessageReader((type, connId, data) => { + // Forward messages from exit to client + if (clientSocket && !clientSocket.destroyed) { + writeMessage(clientSocket, type, connId, data); + } + }); + + sock.on('data', (data) => exitReader(data)); + + sock.on('close', () => { + console.log('Disconnected from exit node. Retrying in 2s...'); + exitSocket = null; + // If client is still here, it will be disconnected by the client server logic + if (clientSocket) { + try { clientSocket.destroy(); } catch (_) {} + } + setTimeout(connectToExitNode, 2000); + }); + + sock.on('error', (err) => { + console.error('Exit node connection error:', err.message); + }); } -function parseUdpPayload(buf) { - // [2B hostLen][host][2B port][2B dataLen][data...] - if (buf.length < 2) return null; - let off = 0; - const hostLen = buf.readUInt16BE(off); off += 2; - if (buf.length < 2 + hostLen + 2 + 2) return null; - const host = buf.subarray(off, off + hostLen).toString('utf8'); off += hostLen; - const port = buf.readUInt16BE(off); off += 2; - const dataLen = buf.readUInt16BE(off); off += 2; - if (buf.length < 2 + hostLen + 2 + 2 + dataLen) return null; - const data = buf.subarray(off, off + dataLen); - return { host, port, data }; -} - -function buildUdpPayload(host, port, data) { - const hostBuf = Buffer.from(host, 'utf8'); - const buf = Buffer.allocUnsafe(2 + hostBuf.length + 2 + 2 + data.length); - let off = 0; - buf.writeUInt16BE(hostBuf.length, off); off += 2; - hostBuf.copy(buf, off); off += hostBuf.length; - buf.writeUInt16BE(port, off); off += 2; - buf.writeUInt16BE(data.length, off); off += 2; - data.copy(buf, off); - return buf; -} - -function pskCallback(socket, identity) { - console.log(`Tunnel client identity: ${identity}`); +const clientPskCallback = (socket, identity) => { + console.log(`Client identity: ${identity}`); return Buffer.from(pskKey, 'hex'); -} +}; -let tunnelSocket = null; -const upstreamConns = new Map(); // connectionId -> net.Socket - -// UDP association mapping: connectionId -> { udp4?: dgram.Socket, udp6?: dgram.Socket } -const udpAssoc = new Map(); - -// Framing and fairness controls -const FRAME_MAX = 16 * 1024; // 16KiB frames to improve interleaving -const BYTES_PER_TICK = 64 * 1024; // Limit per processing burst to yield event loop -// Per-connection TX queues for upstream->tunnel direction -const txQueues = new Map(); // connectionId -> { queue: Buffer[], sending: boolean } - -function processTxQueue(connectionId, state) { - if (!tunnelSocket || tunnelSocket.destroyed) { - // Drop queued data if tunnel is gone - state.queue = []; - state.sending = false; - return; - } - if (state.sending) return; - state.sending = true; - - let bytesThisTick = 0; - - const run = () => { - if (!tunnelSocket || tunnelSocket.destroyed) { - state.queue = []; - state.sending = false; - return; - } - while (state.queue.length) { - const buf = state.queue[0]; - let offset = buf._offset || 0; - - while (offset < buf.length) { - const end = Math.min(offset + FRAME_MAX, buf.length); - const slice = buf.subarray(offset, end); - const ok = writeMessage(tunnelSocket, MSG_TYPES.DATA, connectionId, slice); - if (!ok) { - // Backpressure: remember progress and resume on drain - buf._offset = end; - tunnelSocket.once('drain', () => { - // Reset flag to allow re-entry - state.sending = false; - run(); - }); - return; - } - offset = end; - bytesThisTick += slice.length; - if (bytesThisTick >= BYTES_PER_TICK) { - // Yield to allow other I/O - buf._offset = offset; - bytesThisTick = 0; - setImmediate(() => { - state.sending = false; - run(); - }); - return; - } - } - - // Finished this buffer - delete buf._offset; - state.queue.shift(); - } - - state.sending = false; - }; - - run(); -} - -function enqueueToTunnel(connectionId, data) { - let state = txQueues.get(connectionId); - if (!state) { - state = { queue: [], sending: false }; - txQueues.set(connectionId, state); - } - state.queue.push(data); - // Attempt to process immediately - if (!state.sending) { - state.sending = false; // ensure process can start - processTxQueue(connectionId, state); - } -} - -function closeAllUpstreams() { - for (const [, s] of upstreamConns) { - try { s.destroy(); } catch (_) {} - } - upstreamConns.clear(); -} - -function closeAllUdp() { - for (const [, obj] of udpAssoc) { - try { obj.udp4 && obj.udp4.close(); } catch (_) {} - try { obj.udp6 && obj.udp6.close(); } catch (_) {} - } - udpAssoc.clear(); -} - -function ensureUdpSocketsForAssoc(connectionId) { - if (udpAssoc.has(connectionId)) return udpAssoc.get(connectionId); - const onMessage = (msg, rinfo) => { - // Forward incoming datagrams to client - writeMessage(tunnelSocket, MSG_TYPES.UDP_RECV, connectionId, buildUdpPayload(rinfo.address, rinfo.port, msg)); - }; - // Create both IPv4 and IPv6 sockets to support all targets - const u4 = dgram.createSocket('udp4'); - const u6 = dgram.createSocket('udp6'); - - u4.on('message', onMessage); - u6.on('message', onMessage); - - u4.on('error', (err) => { /* log but keep running */ console.warn(`UDP4 error (conn ${connectionId}): ${err.message}`); }); - u6.on('error', (err) => { /* log but keep running */ console.warn(`UDP6 error (conn ${connectionId}): ${err.message}`); }); - - // Bind to ephemeral ports to receive replies - try { u4.bind(0); } catch (_) {} - try { u6.bind(0); } catch (_) {} - - const entry = { udp4: u4, udp6: u6 }; - udpAssoc.set(connectionId, entry); - return entry; -} - -const server = tls.createServer( +const clientServer = tls.createServer( { - pskCallback, + pskCallback: clientPskCallback, ciphers: 'PSK-AES256-GCM-SHA384:PSK-AES128-GCM-SHA256', }, (socket) => { - console.log('Proxy tunnel client connected'); - tunnelSocket = socket; + if (clientSocket) { + console.log('Rejecting new client connection, already have one.'); + socket.destroy(); + return; + } + console.log('Client connected'); + clientSocket = socket; socket.setNoDelay(true); socket.setKeepAlive(true, 30000); - const reader = createMessageReader(); - - socket.on('data', (data) => { - reader(data, (type, connectionId, payload) => { - if (type === MSG_TYPES.OPEN) { - const spec = parseOpenPayload(payload); - if (!spec) { - console.warn(`Invalid OPEN payload for connection ${connectionId}`); - writeMessage(tunnelSocket, MSG_TYPES.OPEN_RESULT, connectionId, buildOpenResultPayload(false)); - return; - } - - const { host, port } = spec; - // Create outbound TCP connection - const upstream = net.createConnection({ host, port }); - upstream.setNoDelay(true); - upstream.setKeepAlive(true, 30000); - - let connected = false; - const connectTimer = setTimeout(() => { - if (!connected) { - upstream.destroy(new Error('Connect timeout')); - } - }, OUT_CONNECT_TIMEOUT); - - upstream.once('connect', () => { - connected = true; - clearTimeout(connectTimer); - upstreamConns.set(connectionId, upstream); - // Notify open success - writeMessage(tunnelSocket, MSG_TYPES.OPEN_RESULT, connectionId, buildOpenResultPayload(true)); - }); - - upstream.on('data', (chunk) => { - // Queue data with framing and backpressure-aware sending - enqueueToTunnel(connectionId, chunk); - }); - - const cleanup = () => { - clearTimeout(connectTimer); - if (upstreamConns.get(connectionId) === upstream) { - upstreamConns.delete(connectionId); - } - // Drop any pending TX data for this connection - txQueues.delete(connectionId); - writeMessage(tunnelSocket, MSG_TYPES.CLOSE, connectionId); - }; - - upstream.on('error', (err) => { - if (!connected) { - clearTimeout(connectTimer); - writeMessage(tunnelSocket, MSG_TYPES.OPEN_RESULT, connectionId, buildOpenResultPayload(false)); - } else { - // Upstream error after established - cleanup(); - } - }); - - upstream.on('close', () => { - cleanup(); - }); - } else if (type === MSG_TYPES.DATA) { - const upstream = upstreamConns.get(connectionId); - if (upstream && !upstream.destroyed) { - upstream.write(payload); - } - } else if (type === MSG_TYPES.CLOSE) { - const upstream = upstreamConns.get(connectionId); - if (upstream) { - upstream.destroy(); - upstreamConns.delete(connectionId); - } - - // UDP handling - } else if (type === MSG_TYPES.UDP_OPEN) { - try { - ensureUdpSocketsForAssoc(connectionId); - writeMessage(tunnelSocket, MSG_TYPES.UDP_OPEN_RESULT, connectionId, buildOpenResultPayload(true)); - } catch (e) { - console.warn(`Failed to create UDP association for ${connectionId}: ${e.message}`); - writeMessage(tunnelSocket, MSG_TYPES.UDP_OPEN_RESULT, connectionId, buildOpenResultPayload(false)); - } - } else if (type === MSG_TYPES.UDP_SEND) { - const parsed = parseUdpPayload(payload); - if (!parsed) { - console.warn(`Invalid UDP_SEND payload for ${connectionId}`); - return; - } - const { host, port, data } = parsed; - const entry = udpAssoc.get(connectionId) || ensureUdpSocketsForAssoc(connectionId); - // Choose v6 socket if IPv6 literal detected - const isV6 = host.includes(':'); - const sock = isV6 ? entry.udp6 : entry.udp4; - try { - sock.send(data, port, host); - } catch (e) { - console.warn(`UDP send failed (conn ${connectionId}) to ${host}:${port} - ${e.message}`); - } - } else if (type === MSG_TYPES.UDP_CLOSE) { - const entry = udpAssoc.get(connectionId); - if (entry) { - try { entry.udp4 && entry.udp4.close(); } catch (_) {} - try { entry.udp6 && entry.udp6.close(); } catch (_) {} - udpAssoc.delete(connectionId); - } - } else { - // ignore unknown types - } - }); + clientReader = createMessageReader((type, connId, data) => { + // Forward messages from client to exit + if (exitSocket && !exitSocket.destroyed) { + writeMessage(exitSocket, type, connId, data); + } }); + socket.on('data', (data) => clientReader(data)); + socket.on('close', () => { - console.log('Proxy tunnel disconnected'); - tunnelSocket = null; - closeAllUpstreams(); - closeAllUdp(); + console.log('Client disconnected'); + clientSocket = null; + // When client disconnects, we can optionally close the exit connection + // or keep it alive. For simplicity, we keep it. }); socket.on('error', (err) => { - console.error('Tunnel socket error:', err.message); + console.error('Client socket error:', err.message); }); } ); -server.listen(parseInt(options.tunnelPort, 10), options.host, () => { - console.log(`PSK Proxy Out-Node listening on ${options.host}:${options.tunnelPort}`); +clientServer.listen(parseInt(options.tunnelPort, 10), options.host, () => { + console.log(`PSK Proxy Relay-Node listening for clients on ${options.host}:${options.tunnelPort}`); }); +// Start connection to the exit node +connectToExitNode(); + process.on('SIGINT', () => { console.log('Shutting down...'); - try { server.close(); } catch (_) {} - closeAllUpstreams(); - closeAllUdp(); + try { clientServer.close(); } catch (_) {} + if (clientSocket) try { clientSocket.destroy(); } catch (_) {} + if (exitSocket) try { exitSocket.destroy(); } catch (_) {} process.exit(0); -}); +}); \ No newline at end of file