feat: introduce 3-tier architecture with relay and exit nodes
This commit is contained in:
115
BUILD.md
115
BUILD.md
@@ -1,11 +1,12 @@
|
|||||||
# PSK-Proxy-Tunnel Build & Usage Guide
|
# 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:
|
Key changes:
|
||||||
- Local proxy is now SOCKS5 (replaces the previous HTTP proxy).
|
- The architecture is now Client -> Relay -> Exit.
|
||||||
- The tunnel supports multiplexed TCP and UDP relaying.
|
- `proxy-server.js` is now a relay node (`psk-proxy-relay`).
|
||||||
- Existing frame protocol extended with UDP_* frames for SOCKS5 UDP ASSOCIATE.
|
- 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
|
## Prerequisites
|
||||||
|
|
||||||
@@ -85,89 +86,119 @@ Executables are created in `dist/`:
|
|||||||
|
|
||||||
```
|
```
|
||||||
dist/
|
dist/
|
||||||
├── psk-proxy-server-macos
|
|
||||||
├── psk-proxy-client-macos
|
├── psk-proxy-client-macos
|
||||||
├── psk-proxy-server-linux
|
├── psk-proxy-relay-macos
|
||||||
|
├── psk-proxy-exit-macos
|
||||||
├── psk-proxy-client-linux
|
├── psk-proxy-client-linux
|
||||||
├── psk-proxy-server-windows.exe
|
├── psk-proxy-relay-linux
|
||||||
└── psk-proxy-client-windows.exe
|
├── 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):
|
The PSK (pre-shared key) file must contain a hex-encoded key string used by all components.
|
||||||
```
|
|
||||||
0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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.
|
- Performs outbound TCP connects and UDP sends on behalf of the client.
|
||||||
|
|
||||||
macOS/Linux:
|
macOS/Linux:
|
||||||
```bash
|
```bash
|
||||||
./dist/psk-proxy-server-macos \
|
./dist/psk-proxy-exit-macos \
|
||||||
--tunnel-port 8443 \
|
--relay-port 9000 \
|
||||||
--host 0.0.0.0 \
|
--host 0.0.0.0 \
|
||||||
--psk-file /path/to/psk.hex
|
--psk-file /path/to/psk.hex
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows:
|
Windows:
|
||||||
```cmd
|
```cmd
|
||||||
.\dist\psk-proxy-server-windows.exe ^
|
.\dist\psk-proxy-exit-windows.exe ^
|
||||||
--tunnel-port 8443 ^
|
--relay-port 9000 ^
|
||||||
--host 0.0.0.0 ^
|
--host 0.0.0.0 ^
|
||||||
--psk-file C:\path\to\psk.hex
|
--psk-file C:\path\to\psk.hex
|
||||||
```
|
```
|
||||||
|
|
||||||
Required options:
|
Required options:
|
||||||
- `--tunnel-port <port>`: TLS-PSK tunnel port
|
- `--relay-port <port>`: Port for the relay to connect to.
|
||||||
- `--host <host>`: Bind host (e.g., 0.0.0.0)
|
- `--host <host>`: Bind host (e.g., 0.0.0.0).
|
||||||
- `--psk-file <path>`: File containing hex PSK
|
- `--psk-file <path>`: File containing hex PSK.
|
||||||
|
|
||||||
Optional:
|
### 2. Relay Node
|
||||||
- `--connect-timeout <ms>`: Outbound TCP connect timeout (default 10000)
|
|
||||||
|
|
||||||
### 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).
|
macOS/Linux:
|
||||||
- Multiplexes many local connections over one TLS-PSK tunnel to the server.
|
```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>`: Port for the client to connect to.
|
||||||
|
- `--host <host>`: Bind host.
|
||||||
|
- `--psk-file <path>`: File containing hex PSK.
|
||||||
|
- `--exit-host <host>`: Exit node host.
|
||||||
|
- `--exit-port <port>`: Exit node port.
|
||||||
|
- `--exit-identity <id>`: 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:
|
macOS/Linux:
|
||||||
```bash
|
```bash
|
||||||
./dist/psk-proxy-client-macos \
|
./dist/psk-proxy-client-macos \
|
||||||
--server-host server.example.com \
|
--server-host relay.node.com \
|
||||||
--server-port 8443 \
|
--server-port 8443 \
|
||||||
--psk-file /path/to/psk.hex \
|
--psk-file /path/to/psk.hex \
|
||||||
--identity client1 \
|
--identity client1 \
|
||||||
--socks-port 1080 \
|
--socks-port 1080
|
||||||
--bind-host 127.0.0.1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows:
|
Windows:
|
||||||
```cmd
|
```cmd
|
||||||
.\dist\psk-proxy-client-windows.exe ^
|
.\dist\psk-proxy-client-windows.exe ^
|
||||||
--server-host server.example.com ^
|
--server-host relay.node.com ^
|
||||||
--server-port 8443 ^
|
--server-port 8443 ^
|
||||||
--psk-file C:\path\to\psk.hex ^
|
--psk-file C:\path\to\psk.hex ^
|
||||||
--identity client1 ^
|
--identity client1 ^
|
||||||
--socks-port 1080 ^
|
--socks-port 1080
|
||||||
--bind-host 127.0.0.1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Required options:
|
Required options:
|
||||||
- `--server-host <host>`: Remote out-node address
|
- `--server-host <host>`: Relay node address.
|
||||||
- `--server-port <port>`: Remote out-node port
|
- `--server-port <port>`: Relay node port.
|
||||||
- `--psk-file <path>`: File containing hex PSK
|
- `--psk-file <path>`: File containing hex PSK.
|
||||||
- `--identity <id>`: Identity string (logged on server)
|
- `--identity <id>`: Identity string (logged on relay).
|
||||||
- `--socks-port <port>`: Local SOCKS5 proxy port
|
- `--socks-port <port>`: Local SOCKS5 proxy port.
|
||||||
|
|
||||||
Optional:
|
Optional:
|
||||||
- `--bind-host <host>`: Local bind host (default `127.0.0.1`)
|
- `--bind-host <host>`: Local bind host (default `127.0.0.1`).
|
||||||
- `--connect-timeout <ms>`: Waiting time for OPEN/UDP_OPEN result (default 10000)
|
- `--connect-timeout <ms>`: Timeout for connection setup (default 10000).
|
||||||
- `--idle-timeout <ms>`: Idle timeout for TCP sockets (default 60000, 0=disabled)
|
- `--idle-timeout <ms>`: Idle timeout for TCP sockets (default 60000).
|
||||||
- `--udp-idle-timeout <ms>`: Idle timeout for UDP association (default 60000, 0=disabled)
|
- `--udp-idle-timeout <ms>`: Idle timeout for UDP association (default 60000).
|
||||||
|
|
||||||
## Protocol Summary
|
## Protocol Summary
|
||||||
|
|
||||||
|
29
package.json
29
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "psk-proxy-tunnel",
|
"name": "psk-proxy-tunnel",
|
||||||
"version": "1.0.0",
|
"version": "1.1.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",
|
"description": "TLS-PSK multiplexed TCP+UDP tunnel with a 3-tier architecture (client, relay, exit) for secure NAT traversal.",
|
||||||
"main": "proxy-client.js",
|
"main": "proxy-client.js",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -9,17 +9,21 @@
|
|||||||
"clean": "node -e \"try{require('fs').rmSync('dist',{recursive:true,force:true})}catch(e){}\"",
|
"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})\"",
|
"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": "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:macos": "npm run build:relay:macos && npm run build:client:macos && npm run build:exit:macos",
|
||||||
"build:server:macos": "pkg proxy-server.js --targets node18-macos-x64 --output dist/psk-proxy-server-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: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:exit:macos": "pkg proxy-exit.js --targets node18-macos-x64 --output dist/psk-proxy-exit-macos",
|
||||||
"build:server:linux": "pkg proxy-server.js --targets node18-linux-x64 --output dist/psk-proxy-server-linux",
|
"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: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:exit:linux": "pkg proxy-exit.js --targets node18-linux-x64 --output dist/psk-proxy-exit-linux",
|
||||||
"build:server:windows": "pkg proxy-server.js --targets node18-win-x64 --output dist/psk-proxy-server-windows.exe",
|
"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",
|
"build:client:windows": "pkg proxy-client.js --targets node18-win-x64 --output dist/psk-proxy-client-windows.exe",
|
||||||
"start:server": "node proxy-server.js",
|
"build:exit:windows": "pkg proxy-exit.js --targets node18-win-x64 --output dist/psk-proxy-exit-windows.exe",
|
||||||
"start:client": "node proxy-client.js"
|
"start:relay": "node proxy-server.js",
|
||||||
|
"start:client": "node proxy-client.js",
|
||||||
|
"start:exit": "node proxy-exit.js"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -36,8 +40,9 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"psk-proxy-server": "./proxy-server.js",
|
"psk-proxy-relay": "./proxy-server.js",
|
||||||
"psk-proxy-client": "./proxy-client.js"
|
"psk-proxy-client": "./proxy-client.js",
|
||||||
|
"psk-proxy-exit": "./proxy-exit.js"
|
||||||
},
|
},
|
||||||
"pkg": {
|
"pkg": {
|
||||||
"assets": [],
|
"assets": [],
|
||||||
|
426
proxy-exit.js
Normal file
426
proxy-exit.js
Normal file
@@ -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>', 'Port for proxy relay TLS-PSK tunnel connections')
|
||||||
|
.requiredOption('-H, --host <host>', 'Host to bind to (e.g., 0.0.0.0)')
|
||||||
|
.requiredOption('--psk-file <path>', 'Path to PSK key file')
|
||||||
|
.requiredOption('--identity <identity>', 'Expected PSK identity from relay')
|
||||||
|
.option('--connect-timeout <ms>', '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);
|
||||||
|
});
|
447
proxy-server.js
447
proxy-server.js
@@ -1,42 +1,24 @@
|
|||||||
#!/usr/bin/env node
|
#!/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.
|
* Listens for a TLS-PSK tunnel connection from the proxy client.
|
||||||
* Receives OPEN(host,port) to create outbound TCP connections to remote servers,
|
* Establishes a single TLS-PSK tunnel connection to an exit node.
|
||||||
* then forwards DATA/CLOSE frames bidirectionally.
|
* Relays frames between the client and the exit node.
|
||||||
*
|
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const net = require('net');
|
|
||||||
const tls = require('tls');
|
const tls = require('tls');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const dgram = require('dgram');
|
|
||||||
const { program } = require('commander');
|
const { program } = require('commander');
|
||||||
|
|
||||||
program
|
program
|
||||||
.requiredOption('-P, --tunnel-port <port>', 'Port for proxy client TLS-PSK tunnel connections')
|
.requiredOption('-P, --tunnel-port <port>', 'Port for proxy client TLS-PSK tunnel connections')
|
||||||
.requiredOption('-H, --host <host>', 'Host to bind to (e.g., 0.0.0.0)')
|
.requiredOption('-H, --host <host>', 'Host to bind to (e.g., 0.0.0.0)')
|
||||||
.requiredOption('--psk-file <path>', 'Path to PSK key file')
|
.requiredOption('--psk-file <path>', 'Path to PSK key file for client and exit connections')
|
||||||
.option('--connect-timeout <ms>', 'Timeout for outbound TCP connect (ms)', '10000')
|
.requiredOption('--exit-host <host>', 'Exit node host')
|
||||||
|
.requiredOption('--exit-port <port>', 'Exit node port')
|
||||||
|
.requiredOption('--exit-identity <identity>', 'PSK identity for the exit node')
|
||||||
.parse();
|
.parse();
|
||||||
|
|
||||||
const options = program.opts();
|
const options = program.opts();
|
||||||
@@ -49,41 +31,20 @@ try {
|
|||||||
process.exit(1);
|
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
|
function createMessageReader(onMessage) {
|
||||||
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 buffer = Buffer.alloc(0);
|
||||||
let expectedLength = 9;
|
let expectedLength = 9;
|
||||||
let currentMessage = null;
|
let currentMessage = null;
|
||||||
|
|
||||||
return function onData(data, callback) {
|
return function onData(data) {
|
||||||
buffer = Buffer.concat([buffer, data]);
|
buffer = Buffer.concat([buffer, data]);
|
||||||
|
|
||||||
// Parse as many complete frames as possible
|
|
||||||
while (buffer.length >= expectedLength) {
|
while (buffer.length >= expectedLength) {
|
||||||
if (!currentMessage) {
|
if (!currentMessage) {
|
||||||
const type = buffer.readUInt8(0);
|
const type = buffer.readUInt8(0);
|
||||||
@@ -93,14 +54,14 @@ function createMessageReader() {
|
|||||||
expectedLength = 9 + dataLength;
|
expectedLength = 9 + dataLength;
|
||||||
|
|
||||||
if (dataLength === 0) {
|
if (dataLength === 0) {
|
||||||
callback(currentMessage.type, currentMessage.connectionId, Buffer.alloc(0));
|
onMessage(currentMessage.type, currentMessage.connectionId, Buffer.alloc(0));
|
||||||
buffer = buffer.subarray(9);
|
buffer = buffer.subarray(9);
|
||||||
currentMessage = null;
|
currentMessage = null;
|
||||||
expectedLength = 9;
|
expectedLength = 9;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const messageData = buffer.subarray(9, expectedLength);
|
const messageData = buffer.subarray(9, expectedLength);
|
||||||
callback(currentMessage.type, currentMessage.connectionId, messageData);
|
onMessage(currentMessage.type, currentMessage.connectionId, messageData);
|
||||||
buffer = buffer.subarray(expectedLength);
|
buffer = buffer.subarray(expectedLength);
|
||||||
currentMessage = null;
|
currentMessage = null;
|
||||||
expectedLength = 9;
|
expectedLength = 9;
|
||||||
@@ -109,324 +70,120 @@ function createMessageReader() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseOpenPayload(buf) {
|
function writeMessage(socket, type, connectionId, data = Buffer.alloc(0)) {
|
||||||
if (buf.length < 4) return null;
|
if (!socket || socket.destroyed) return;
|
||||||
let offset = 0;
|
const buf = Buffer.allocUnsafe(9 + data.length);
|
||||||
const hostLen = buf.readUInt16BE(offset); offset += 2;
|
buf.writeUInt8(type, 0);
|
||||||
if (buf.length < 2 + hostLen + 2) return null;
|
buf.writeUInt32BE(connectionId >>> 0, 1);
|
||||||
const host = buf.subarray(offset, offset + hostLen).toString('utf8'); offset += hostLen;
|
buf.writeUInt32BE(data.length >>> 0, 5);
|
||||||
const port = buf.readUInt16BE(offset); offset += 2;
|
if (data.length > 0) data.copy(buf, 9);
|
||||||
return { host, port };
|
socket.write(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOpenResultPayload(success) {
|
function connectToExitNode() {
|
||||||
const b = Buffer.allocUnsafe(1);
|
console.log(`Connecting to exit node ${options.exitHost}:${options.exitPort}...`);
|
||||||
b.writeUInt8(success ? 1 : 0, 0);
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseUdpPayload(buf) {
|
const pskCb = () => ({
|
||||||
// [2B hostLen][host][2B port][2B dataLen][data...]
|
identity: options.exitIdentity,
|
||||||
if (buf.length < 2) return null;
|
psk: Buffer.from(pskKey, 'hex'),
|
||||||
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}`);
|
|
||||||
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
|
const sock = tls.connect(
|
||||||
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(
|
|
||||||
{
|
{
|
||||||
pskCallback,
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientPskCallback = (socket, identity) => {
|
||||||
|
console.log(`Client identity: ${identity}`);
|
||||||
|
return Buffer.from(pskKey, 'hex');
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientServer = tls.createServer(
|
||||||
|
{
|
||||||
|
pskCallback: clientPskCallback,
|
||||||
ciphers: 'PSK-AES256-GCM-SHA384:PSK-AES128-GCM-SHA256',
|
ciphers: 'PSK-AES256-GCM-SHA384:PSK-AES128-GCM-SHA256',
|
||||||
},
|
},
|
||||||
(socket) => {
|
(socket) => {
|
||||||
console.log('Proxy tunnel client connected');
|
if (clientSocket) {
|
||||||
tunnelSocket = socket;
|
console.log('Rejecting new client connection, already have one.');
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('Client connected');
|
||||||
|
clientSocket = socket;
|
||||||
|
|
||||||
socket.setNoDelay(true);
|
socket.setNoDelay(true);
|
||||||
socket.setKeepAlive(true, 30000);
|
socket.setKeepAlive(true, 30000);
|
||||||
|
|
||||||
const reader = createMessageReader();
|
clientReader = createMessageReader((type, connId, data) => {
|
||||||
|
// Forward messages from client to exit
|
||||||
socket.on('data', (data) => {
|
if (exitSocket && !exitSocket.destroyed) {
|
||||||
reader(data, (type, connectionId, payload) => {
|
writeMessage(exitSocket, type, connId, data);
|
||||||
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', () => {
|
socket.on('data', (data) => clientReader(data));
|
||||||
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
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('close', () => {
|
socket.on('close', () => {
|
||||||
console.log('Proxy tunnel disconnected');
|
console.log('Client disconnected');
|
||||||
tunnelSocket = null;
|
clientSocket = null;
|
||||||
closeAllUpstreams();
|
// When client disconnects, we can optionally close the exit connection
|
||||||
closeAllUdp();
|
// or keep it alive. For simplicity, we keep it.
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
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, () => {
|
clientServer.listen(parseInt(options.tunnelPort, 10), options.host, () => {
|
||||||
console.log(`PSK Proxy Out-Node listening on ${options.host}:${options.tunnelPort}`);
|
console.log(`PSK Proxy Relay-Node listening for clients on ${options.host}:${options.tunnelPort}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Start connection to the exit node
|
||||||
|
connectToExitNode();
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
console.log('Shutting down...');
|
console.log('Shutting down...');
|
||||||
try { server.close(); } catch (_) {}
|
try { clientServer.close(); } catch (_) {}
|
||||||
closeAllUpstreams();
|
if (clientSocket) try { clientSocket.destroy(); } catch (_) {}
|
||||||
closeAllUdp();
|
if (exitSocket) try { exitSocket.destroy(); } catch (_) {}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
Reference in New Issue
Block a user