From b98cd1bb227c0b855c7b7f67e012528a9aac81b1 Mon Sep 17 00:00:00 2001 From: Jaydenha09 Date: Fri, 5 Sep 2025 14:26:57 +0800 Subject: [PATCH] use socks5 instead of HTTP Proxy to allow more protocols --- BUILD.md | 383 ++++++++++++------------ package.json | 2 +- proxy-client.js | 779 +++++++++++++++++++++++++++++++----------------- proxy-server.js | 285 ++++++++++++------ 4 files changed, 902 insertions(+), 547 deletions(-) diff --git a/BUILD.md b/BUILD.md index 64aaa4e..0c7a65e 100644 --- a/BUILD.md +++ b/BUILD.md @@ -1,37 +1,45 @@ -# PSK-Proxy-Tunnel Build Guide +# PSK-Proxy-Tunnel Build & Usage Guide -This guide explains how to build single executable binaries for the PSK-Proxy-Tunnel project using the `pkg` tool. +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). + +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. ## Prerequisites -- **Node.js**: Version 18.0.0 or higher -- **npm**: Usually comes with Node.js -- **Git**: To clone the repository +- Node.js: Version 18.0.0 or higher +- npm: Usually comes with Node.js +- Git: To clone the repository ## Quick Start -### 1. Install Dependencies +### 1) Install dependencies ```bash npm install ``` -### 2. Build for Current Platform +### 2) Build for your current platform ```bash -# macOS/Linux: auto-detect current platform via script +# macOS/Linux ./build.sh -# Windows: auto-detect Windows via script +# Windows build.bat - -# Or run a specific platform via npm -npm run build:macos # macOS -npm run build:linux # Linux -npm run build:windows # Windows ``` -### 3. Build for All Platforms +Or via npm directly: + +```bash +npm run build:macos # macOS +npm run build:linux # Linux +npm run build:windows # Windows +``` + +### 3) Build for all platforms ```bash npm run build @@ -39,133 +47,217 @@ npm run build ## Build Scripts -### Using the Build Scripts +### Using the build scripts -#### Unix/Linux/macOS +Unix/Linux/macOS: ```bash -# Make the script executable chmod +x build.sh - -# Build for current platform -./build.sh - -# Build for specific platform +./build.sh # current platform ./build.sh --platform macos ./build.sh --platform linux ./build.sh --platform windows - -# Build for all platforms -./build.sh --all - -# Clean build artifacts -./build.sh --clean - -# Show help +./build.sh --all # all platforms +./build.sh --clean # remove dist/ ./build.sh --help ``` -#### Windows +Windows: ```cmd -# Build for Windows (default) -build.bat - -# Build for all platforms -build.bat --all - -# Clean build artifacts +build.bat # Windows (default) +build.bat --all # all platforms build.bat --clean - -# Show help build.bat --help ``` -### Using npm Scripts Directly +### Using npm scripts directly ```bash -# Build for specific platform npm run build:macos npm run build:linux npm run build:windows - -# Build for all platforms -npm run build - -# Clean build artifacts -npm run clean +npm run build # all platforms +npm run clean # remove dist/ ``` ## Build Output -After building, you'll find the executables in the `dist/` directory: +Executables are created in `dist/`: ``` dist/ -├── psk-proxy-server-macos # macOS server executable -├── psk-proxy-client-macos # macOS client executable -├── psk-proxy-server-linux # Linux server executable -├── psk-proxy-client-linux # Linux client executable -├── psk-proxy-server-windows.exe # Windows server executable -└── psk-proxy-client-windows.exe # Windows client executable +├── psk-proxy-server-macos +├── psk-proxy-client-macos +├── psk-proxy-server-linux +├── psk-proxy-client-linux +├── psk-proxy-server-windows.exe +└── psk-proxy-client-windows.exe ``` -## Platform-Specific Builds +## Running the Server and Client -### macOS +The PSK (pre-shared key) file must contain a hex-encoded key string used by both sides. Example (256-bit key): +``` +0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +``` + +### Server (Out-Node) + +- Listens for a single TLS-PSK tunnel connection from the client. +- Performs outbound TCP connects and UDP sends on behalf of the client. + +macOS/Linux: ```bash -npm run build:macos -# Creates: psk-proxy-server-macos, psk-proxy-client-macos +./dist/psk-proxy-server-macos \ + --tunnel-port 8443 \ + --host 0.0.0.0 \ + --psk-file /path/to/psk.hex ``` -### Linux +Windows: +```cmd +.\dist\psk-proxy-server-windows.exe ^ + --tunnel-port 8443 ^ + --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 + +Optional: +- `--connect-timeout `: Outbound TCP connect timeout (default 10000) + +### Client (Local SOCKS5 Proxy) + +- 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 -npm run build:linux -# Creates: psk-proxy-server-linux, psk-proxy-client-linux +./dist/psk-proxy-client-macos \ + --server-host server.example.com \ + --server-port 8443 \ + --psk-file /path/to/psk.hex \ + --identity client1 \ + --socks-port 1080 \ + --bind-host 127.0.0.1 ``` -### Windows +Windows: +```cmd +.\dist\psk-proxy-client-windows.exe ^ + --server-host server.example.com ^ + --server-port 8443 ^ + --psk-file C:\path\to\psk.hex ^ + --identity client1 ^ + --socks-port 1080 ^ + --bind-host 127.0.0.1 +``` + +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 + +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) + +## Protocol Summary + +All multiplexed over a single TLS-PSK socket: + +Header (9 bytes): +- 1 byte type +- 4 bytes connection id (unsigned) +- 4 bytes payload length (unsigned) +- payload bytes + +Types: +- TCP: + - DATA (2): stream data + - CLOSE (3): close stream + - OPEN (4): payload = [2B hostLen][host][2B port] + - OPEN_RESULT (5): payload = [1B status] (1=success, 0=failure) +- UDP (for SOCKS5 UDP ASSOCIATE): + - UDP_OPEN (6): payload empty + - UDP_OPEN_RESULT (7): payload = [1B status] (1=success, 0=failure) + - UDP_SEND (8): payload = [2B hostLen][host][2B port][2B dataLen][data] + - UDP_RECV (9): same layout as UDP_SEND + - UDP_CLOSE (10): payload empty + +## Testing + +Assuming the client runs a local SOCKS5 proxy at 127.0.0.1:1080: + +- HTTP over SOCKS5 (remote DNS resolution via socks5h): + ```bash + curl -x socks5h://127.0.0.1:1080 http://example.com/ + ``` + +- HTTPS over SOCKS5: + ```bash + curl -x socks5h://127.0.0.1:1080 https://ifconfig.me + ``` + +- SSH over SOCKS5 (using `nc` as ProxyCommand): + ```bash + ssh -o ProxyCommand="nc -x 127.0.0.1:1080 -X 5 %h %p" user@your.remote.host + ``` + +- Applications with SOCKS5 (TCP and UDP): + - Many apps support SOCKS5 TCP CONNECT (browsers, package managers, etc.). + - For UDP ASSOCIATE (e.g., some torrent clients, DNS forwarders with SOCKS5-UDP support), configure them to use 127.0.0.1:1080 SOCKS5 and enable UDP if supported. Note: many CLI tools do not implement SOCKS5 UDP. + +Notes: +- `socks5h` in curl ensures DNS names are resolved through the proxy. +- UDP test coverage depends on the client application having SOCKS5 UDP support. + +## Troubleshooting + +1) "pkg command not found" ```bash -npm run build:windows -# Creates: psk-proxy-server-windows.exe, psk-proxy-client-windows.exe +npm install -g pkg +# or +npx pkg proxy-server.js ``` -## Cross-Platform Building - -You can build for other platforms from any operating system: - +2) Build fails with permission errors ```bash -# Build for all platforms from macOS -npm run build - -# Build for all platforms from Linux -npm run build - -# Build for all platforms from Windows -npm run build +chmod +x build.sh +sudo npm run build # if necessary ``` -## Using the Built Executables +3) Executable doesn't run on target platform +- Ensure you built for the correct target +- Verify executable permissions +- Confirm compatible Node target in `pkg` config -### Server -```bash -# macOS/Linux -./dist/psk-proxy-server-macos --tunnel-port 8443 --tcp-port 8080 --host 0.0.0.0 --psk-file /path/to/psk.key +4) Tunnel connects but traffic fails +- Confirm both server and client use the exact same hex PSK +- Check connectivity from server to target hosts/ports (firewall, outbound rules) +- Use `--connect-timeout` to tune behavior -# Windows -.\dist\psk-proxy-server-windows.exe --tunnel-port 8443 --tcp-port 8080 --host 0.0.0.0 --psk-file C:\path\to\psk.key -``` +5) SOCKS5 UDP doesn't appear to work +- Confirm your application actually supports SOCKS5 UDP ASSOCIATE +- Check that the client printed "Local SOCKS5 proxy listening ..." +- Inspect server logs for UDP_* activity -### Client -```bash -# macOS/Linux -./dist/psk-proxy-client-macos --server-host server.example.com --server-port 8443 --origin-host 127.0.0.1 --origin-port 8080 --psk-file /path/to/psk.key --identity client1 +## Performance Considerations -# Windows -.\dist\psk-proxy-client-windows.exe --server-host server.example.com --server-port 8443 --origin-host 127.0.0.1 --origin-port 8080 --psk-file C:\path\to\psk.key --identity client1 -``` +- Single tunnel multiplexes many flows (reduced connection overhead). +- UDP sockets are ephemeral and idle-timed to conserve resources. +- TCP and UDP buffers are kept minimal; tune OS limits as needed. ## Build Configuration -The build configuration is defined in `package.json` under the `pkg` section: +Defined in `package.json` under `pkg`: ```json { @@ -174,110 +266,15 @@ The build configuration is defined in `package.json` under the `pkg` section: "scripts": [], "targets": [ "node18-macos-x64", - "node18-linux-x64", + "node18-linux-x64", "node18-win-x64" ] } } ``` -### Available Targets - -- `node18-macos-x64`: macOS 64-bit -- `node18-linux-x64`: Linux 64-bit -- `node18-win-x64`: Windows 64-bit - -You can modify these targets to support different Node.js versions or architectures. - -## Troubleshooting - -### Common Issues - -1. **"pkg command not found"** - ```bash - npm install -g pkg - # or use the local version - npx pkg proxy-server.js - ``` - -2. **Build fails with permission errors** - ```bash - # Make sure the build script is executable - chmod +x build.sh - - # Or run with sudo if needed - sudo npm run build - ``` - -3. **Executable doesn't run on target platform** - - Ensure you're building for the correct target platform - - Check that the target platform supports the Node.js version you're using - - Verify the executable has proper permissions - -4. **Missing dependencies in built executable** - - The `pkg` tool automatically bundles Node.js built-in modules - - External dependencies are included automatically - - If you have custom assets, add them to the `pkg.assets` array - -### Performance Considerations - -- **File Size**: Single executables are larger than the source code because they include Node.js runtime -- **Startup Time**: Slightly slower startup compared to running with `node` directly -- **Memory Usage**: Similar to running with Node.js directly - -### Security Notes - -- Built executables contain your source code (though it's compiled) -- The `pkg` tool doesn't obfuscate or encrypt your code -- Consider this when distributing executables with sensitive information - -## Advanced Configuration - -### Custom pkg Options - -You can customize the build process by modifying the `pkg` section in `package.json`: - -```json -{ - "pkg": { - "assets": [ - "config/*.json", - "templates/*.html" - ], - "scripts": [ - "scripts/**/*.js" - ], - "targets": [ - "node18-macos-x64", - "node18-linux-x64", - "node18-win-x64" - ], - "outputPath": "custom-dist" - } -} -``` - -### Environment Variables - -You can set environment variables to customize the build: - -```bash -# Set custom output directory -PKG_OUTPUT_PATH=./my-dist npm run build - -# Set custom Node.js version -PKG_NODE_VERSION=16 npm run build -``` - ## Contributing -When adding new build targets or modifying the build process: - -1. Update the `package.json` scripts -2. Update the build scripts (`build.sh` and `build.bat`) -3. Test builds on all target platforms -4. Update this documentation - -## License - -This build system is part of the PSK-Proxy-Tunnel project and follows the same license terms. +- Keep CLI flags in `proxy-client.js` and `proxy-server.js` in sync with this document. +- Update build scripts (`build.sh`, `build.bat`) when adding platforms. +- Test TCP (HTTP/HTTPS/SSH) and at least one UDP-capable client where possible. diff --git a/package.json b/package.json index 0a3b082..dad7669 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "psk-proxy-tunnel", "version": "1.0.0", - "description": "TLS-PSK multiplexed TCP tunnel server and local HTTP proxy client for secure NAT traversal and TCP forwarding", + "description": "TLS-PSK multiplexed TCP+UDP tunnel server and local SOCKS5 proxy client (CONNECT and UDP ASSOCIATE) for secure NAT traversal and protocol forwarding", "main": "proxy-client.js", "type": "commonjs", "scripts": { diff --git a/proxy-client.js b/proxy-client.js index 19fd290..e27336b 100644 --- a/proxy-client.js +++ b/proxy-client.js @@ -1,9 +1,12 @@ #!/usr/bin/env node /** - * PSK HTTP/HTTPS Proxy (Client) + * PSK SOCKS5 Proxy (Client) * - * - Runs a local HTTP proxy (supports CONNECT for HTTPS and absolute/origin-form HTTP for port 80) + * - Runs a local SOCKS5 proxy (supports: + * - CONNECT for arbitrary TCP protocols (HTTP, HTTPS, SSH, etc.) + * - UDP ASSOCIATE for UDP-based protocols (e.g., DNS) + * - BIND: not supported, returns error) * - Maintains a single TLS-PSK tunnel to a remote out-node (proxy-server.js) * - Multiplexes many local client connections over one TLS tunnel using frames: * @@ -11,28 +14,32 @@ * Types: * DATA (2) * CLOSE (3) - * OPEN (4) payload = [2B hostLen][host utf8][2B port] - * OPEN_RESULT (5) payload = [1B status] 1=success,0=failure + * OPEN (4) payload = [2B hostLen][host utf8][2B port] + * OPEN_RESULT (5) payload = [1B status] 1=success,0=failure * - * HTTPS: Use CONNECT method. We respond 200 after OPEN_RESULT success, then tunnel raw TLS. - * HTTP: Convert absolute-form to origin-form and forward over opened upstream (default port 80 if not specified). + * UDP_OPEN (6) payload = empty + * UDP_OPEN_RESULT (7) payload = [1B status] + * UDP_SEND (8) payload = [2B hostLen][host][2B port][2B dataLen][data...] + * UDP_RECV (9) payload = same as UDP_SEND (host:port,data) + * UDP_CLOSE (10) payload = empty */ const net = require('net'); const tls = require('tls'); const fs = require('fs'); -const { URL } = require('url'); +const dgram = require('dgram'); const { program } = require('commander'); program .requiredOption('--server-host ', 'Proxy out-node server host') .requiredOption('--server-port ', 'Proxy out-node server port') - .requiredOption('--psk-file ', 'Path to PSK key file') + .requiredOption('--psk-file ', 'Path to PSK key file (hex)') .requiredOption('--identity ', 'PSK identity string') - .requiredOption('--proxy-port ', 'Local HTTP proxy port to listen on') + .requiredOption('--socks-port ', 'Local SOCKS5 proxy port to listen on') .option('--bind-host ', 'Local bind host', '127.0.0.1') - .option('--connect-timeout ', 'Timeout waiting OPEN_RESULT (ms)', '10000') - .option('--idle-timeout ', 'Idle timeout for local sockets (ms)', '60000') + .option('--connect-timeout ', 'Timeout waiting OPEN/UDP_OPEN result (ms)', '10000') + .option('--idle-timeout ', 'Idle timeout for local TCP sockets (ms)', '60000') + .option('--udp-idle-timeout ', 'Idle timeout for UDP associate socket (ms, 0=never)', '60000') .parse(); const options = program.opts(); @@ -47,6 +54,7 @@ try { const OPEN_RESULT_TIMEOUT = parseInt(options.connectTimeout, 10) || 10000; const IDLE_TIMEOUT = parseInt(options.idleTimeout, 10) || 60000; +const UDP_IDLE_TIMEOUT = parseInt(options.udpIdleTimeout, 10) || 60000; // Message Types const MSG_TYPES = { @@ -54,6 +62,12 @@ const MSG_TYPES = { CLOSE: 3, OPEN: 4, OPEN_RESULT: 5, + + UDP_OPEN: 6, + UDP_OPEN_RESULT: 7, + UDP_SEND: 8, + UDP_RECV: 9, + UDP_CLOSE: 10, }; function buildOpenPayload(host, port) { @@ -66,6 +80,18 @@ function buildOpenPayload(host, port) { return buf; } +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 writeMessage(socket, type, connectionId, data = Buffer.alloc(0)) { if (!socket || socket.destroyed) return; const header = Buffer.allocUnsafe(9); @@ -112,7 +138,7 @@ function createMessageReader() { function pskCallback(/* hint */) { return { identity: options.identity, - psk: Buffer.from(pskKey, 'hex') + psk: Buffer.from(pskKey, 'hex'), }; } @@ -120,15 +146,22 @@ function pskCallback(/* hint */) { let tunnelSocket = null; let reader = null; -const connections = new Map(); // connectionId -> ConnState +// TCP connection state: id -> { id, localSocket, opened, openTimer } +const connections = new Map(); let nextConnId = 1; +// UDP association state: udpId -> { +// id, controlSocket, udpSocket, openTimer, opened, +// clients: Set of "host:port" strings to reply to, +// } +const udpAssocs = new Map(); + function genConnId() { let id = nextConnId >>> 0; do { id = (id + 1) >>> 0; if (id === 0) id = 1; - } while (connections.has(id)); + } while (connections.has(id) || udpAssocs.has(id)); nextConnId = id; return id; } @@ -138,15 +171,18 @@ function connectTunnel() { const port = parseInt(options.serverPort, 10); console.log(`Connecting to proxy out-node ${host}:${port} via TLS-PSK...`); - const sock = tls.connect({ - host, - port, - pskCallback, - ciphers: 'PSK-AES256-GCM-SHA384:PSK-AES128-GCM-SHA256', - checkServerIdentity: () => undefined - }, () => { - console.log('Proxy tunnel connected'); - }); + const sock = tls.connect( + { + host, + port, + pskCallback, + ciphers: 'PSK-AES256-GCM-SHA384:PSK-AES128-GCM-SHA256', + checkServerIdentity: () => undefined, + }, + () => { + console.log('Proxy tunnel connected'); + } + ); sock.setNoDelay(true); sock.setKeepAlive(true, 30000); @@ -156,66 +192,94 @@ function connectTunnel() { sock.on('data', (data) => { reader(data, (type, connectionId, payload) => { - const st = connections.get(connectionId); - if (!st) { - // Unknown or already closed - if (type === MSG_TYPES.OPEN_RESULT) { - // No state; ignore - } - return; - } - if (type === MSG_TYPES.OPEN_RESULT) { + const st = connections.get(connectionId); + if (!st) return; const ok = payload.length > 0 ? payload.readUInt8(0) === 1 : false; if (st.opened) return; clearTimeout(st.openTimer); if (!ok) { - // Notify client error - if (st.mode === 'CONNECT') { - safeWrite(st.localSocket, Buffer.from( - 'HTTP/1.1 502 Bad Gateway\r\nProxy-Agent: PSK-Proxy\r\n\r\n' - )); - } else { - safeWrite(st.localSocket, Buffer.from( - 'HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 11\r\nContent-Type: text/plain\r\n\r\nBad Gateway' - )); - } - destroyLocal(connectionId); + // SOCKS5 CONNECT failure -> reply and close + try { + const rep = buildSocksReply(0x05 /* connection refused */, '0.0.0.0', 0); + st.localSocket.write(rep); + } catch (_) {} + destroyLocalTCP(connectionId); return; } - st.opened = true; - // For CONNECT: send 200 OK to client, then flush any buffered data (likely none) - if (st.mode === 'CONNECT') { - safeWrite(st.localSocket, Buffer.from( - 'HTTP/1.1 200 Connection Established\r\nProxy-Agent: PSK-Proxy\r\n\r\n' - )); - // If there was any extra data after headers (shouldn't for CONNECT), flush it - flushBufferedToTunnel(connectionId); - } else { - // HTTP mode: first send the rewritten initial request, then any buffered tail - if (st.initialUpstream && st.initialUpstream.length > 0) { - writeMessage(tunnelSocket, MSG_TYPES.DATA, connectionId, st.initialUpstream); - st.initialUpstream = null; - } - flushBufferedToTunnel(connectionId); - } + // Reply success for CONNECT + try { + const rep = buildSocksReply(0x00 /* succeeded */, '0.0.0.0', 0); + st.localSocket.write(rep); + } catch (_) {} + // Switch to streaming mode + armTcpStreaming(st); } else if (type === MSG_TYPES.DATA) { - if (!st.localSocket.destroyed) { + const st = connections.get(connectionId); + if (st && !st.localSocket.destroyed) { st.localSocket.write(payload); } } else if (type === MSG_TYPES.CLOSE) { - // Upstream closed - destroyLocal(connectionId); + destroyLocalTCP(connectionId); + + } else if (type === MSG_TYPES.UDP_OPEN_RESULT) { + const as = udpAssocs.get(connectionId); + if (!as) return; + const ok = payload.length > 0 ? payload.readUInt8(0) === 1 : false; + clearTimeout(as.openTimer); + if (!ok) { + // Tell SOCKS5 client UDP associate failed + try { + const rep = buildSocksReply(0x01 /* general failure */, '0.0.0.0', 0); + as.controlSocket.write(rep); + } catch (_) {} + closeUdpAssoc(connectionId); + try { as.controlSocket.destroy(); } catch (_) {} + udpAssocs.delete(connectionId); + return; + } + as.opened = true; + // Reply with our UDP bind address to client + const addrInfo = as.udpSocket.address(); + const bndAddr = isIPv6Address(addrInfo.address) ? '::' : addrInfo.address || '0.0.0.0'; + const bndPort = addrInfo.port || 0; + try { + const rep = buildSocksReply(0x00 /* succeeded */, bndAddr, bndPort); + as.controlSocket.write(rep); + } catch (_) {} + // Keep control connection open; UDP messages handled on udpSocket + } else if (type === MSG_TYPES.UDP_RECV) { + // Payload: [hostLen][host][port][dataLen][data...] + const parsed = parseUdpPayload(payload); + if (!parsed) return; + const as = udpAssocs.get(connectionId); + if (!as) return; + // Build SOCKS5 UDP response and send to all known client endpoints + const { host, port, data } = parsed; + const udpPacket = buildSocksUdpDatagram(host, port, data); + for (const ep of as.clients) { + const [caddr, cportStr] = ep.split(':'); + const cport = parseInt(cportStr, 10); + try { + as.udpSocket.send(udpPacket, cport, caddr); + } catch (_) {} + } + } else { + // ignore unknown message types } }); }); sock.on('close', () => { console.log('Proxy tunnel closed. Cleaning up local connections and retrying in 2s...'); - // Close all local connections + // Close all local TCP connections for (const [id] of connections) { - destroyLocal(id); + destroyLocalTCP(id); + } + // Close all UDP assocs + for (const [id] of udpAssocs) { + closeUdpAssoc(id); } tunnelSocket = null; setTimeout(connectTunnel, 2000); @@ -227,21 +291,11 @@ function connectTunnel() { } function safeWrite(socket, buf) { - if (!socket.destroyed) { - try { socket.write(buf); } catch (_) {} - } + if (!socket || socket.destroyed) return; + try { socket.write(buf); } catch (_) {} } -function flushBufferedToTunnel(connectionId) { - const st = connections.get(connectionId); - if (!st || !tunnelSocket || tunnelSocket.destroyed) return; - while (st.bufferQueue.length > 0) { - const chunk = st.bufferQueue.shift(); - writeMessage(tunnelSocket, MSG_TYPES.DATA, connectionId, chunk); - } -} - -function destroyLocal(connectionId) { +function destroyLocalTCP(connectionId) { const st = connections.get(connectionId); if (!st) return; connections.delete(connectionId); @@ -249,71 +303,215 @@ function destroyLocal(connectionId) { try { st.localSocket.destroy(); } catch (_) {} } -function parseHeadersUntil(buffer) { - const idx = buffer.indexOf('\r\n\r\n'); - if (idx === -1) return null; - const headerPart = buffer.subarray(0, idx).toString('utf8'); - const rest = buffer.subarray(idx + 4); - const lines = headerPart.split('\r\n'); - const requestLine = lines.shift() || ''; - const headers = {}; - for (const line of lines) { - const p = line.indexOf(':'); - if (p > -1) { - const name = line.slice(0, p).trim(); - const val = line.slice(p + 1).trim(); - const key = name.toLowerCase(); - if (headers[key] === undefined) headers[key] = val; - else if (Array.isArray(headers[key])) headers[key].push(val); - else headers[key] = [headers[key], val]; - } - } - return { requestLine, rawHeaderLines: lines, headers, rest }; -} - -function buildRequestLine(method, target, version) { - return `${method} ${target} ${version}`; -} - -function startOpen(connectionId, host, port, st) { +function startOpenTcp(connectionId, host, port, st) { if (!tunnelSocket || tunnelSocket.destroyed) { - // No tunnel available - if (st.mode === 'CONNECT') { - safeWrite(st.localSocket, Buffer.from( - 'HTTP/1.1 503 Service Unavailable\r\nProxy-Agent: PSK-Proxy\r\n\r\n' - )); - } else { - safeWrite(st.localSocket, Buffer.from( - 'HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 19\r\nContent-Type: text/plain\r\n\r\nService Unavailable' - )); - } - destroyLocal(connectionId); + // Reply SOCKS failure + try { + const rep = buildSocksReply(0x01 /* general failure */, '0.0.0.0', 0); + st.localSocket.write(rep); + } catch (_) {} + destroyLocalTCP(connectionId); return; } - // Set a timer for open result st.openTimer = setTimeout(() => { if (!st.opened) { - // Timeout - if (st.mode === 'CONNECT') { - safeWrite(st.localSocket, Buffer.from( - 'HTTP/1.1 504 Gateway Timeout\r\nProxy-Agent: PSK-Proxy\r\n\r\n' - )); - } else { - safeWrite(st.localSocket, Buffer.from( - 'HTTP/1.1 504 Gateway Timeout\r\nConnection: close\r\nContent-Length: 15\r\nContent-Type: text/plain\r\n\r\nGateway Timeout' - )); - } - // Best-effort to close upstream + try { + const rep = buildSocksReply(0x06 /* TTL expired (timeout) */, '0.0.0.0', 0); + st.localSocket.write(rep); + } catch (_) {} writeMessage(tunnelSocket, MSG_TYPES.CLOSE, connectionId); - destroyLocal(connectionId); + destroyLocalTCP(connectionId); } }, OPEN_RESULT_TIMEOUT); writeMessage(tunnelSocket, MSG_TYPES.OPEN, connectionId, buildOpenPayload(host, port)); } -function handleLocalConnection(localSocket) { +function armTcpStreaming(st) { + // After CONNECT success, forward subsequent data + const forward = (chunk) => { + if (!tunnelSocket || tunnelSocket.destroyed) return; + writeMessage(tunnelSocket, MSG_TYPES.DATA, st.id, chunk); + }; + st.localSocket.on('data', forward); + st.localSocket.once('close', () => { + writeMessage(tunnelSocket, MSG_TYPES.CLOSE, st.id); + connections.delete(st.id); + clearTimeout(st.openTimer); + }); + st.localSocket.once('error', () => { + writeMessage(tunnelSocket, MSG_TYPES.CLOSE, st.id); + connections.delete(st.id); + clearTimeout(st.openTimer); + }); +} + +/* ============== SOCKS5 Helpers ============== */ + +function isIPv4Address(addr) { + return net.isIPv4(addr); +} +function isIPv6Address(addr) { + return net.isIPv6(addr); +} + +function buildSocksReply(rep, bndAddr, bndPort) { + // VER=5, REP, RSV=0, ATYP, BND.ADDR, BND.PORT + const addrBufInfo = encodeSocksAddr(bndAddr); + const buf = Buffer.allocUnsafe(4 + addrBufInfo.addr.length + 2); + let off = 0; + buf.writeUInt8(5, off++); // VER + buf.writeUInt8(rep, off++); // REP + buf.writeUInt8(0, off++); // RSV + buf.writeUInt8(addrBufInfo.atyp, off++); // ATYP + addrBufInfo.addr.copy(buf, off); off += addrBufInfo.addr.length; + buf.writeUInt16BE(bndPort >>> 0, off); off += 2; + return buf; +} + +function encodeSocksAddr(addr) { + if (isIPv4Address(addr)) { + return { atyp: 0x01, addr: Buffer.from(addr.split('.').map((x) => parseInt(x, 10))) }; + } + if (isIPv6Address(addr)) { + return { atyp: 0x04, addr: ipToBuffer(addr) }; + } + const nameBuf = Buffer.from(addr, 'utf8'); + const len = Math.min(nameBuf.length, 255); + const nb = nameBuf.subarray(0, len); + return { atyp: 0x03, addr: Buffer.concat([Buffer.from([nb.length]), nb]) }; +} + +function ipToBuffer(ip) { + // Convert IPv6 or IPv4-mapped IPv6 to buffer + if (isIPv4Address(ip)) { + return Buffer.from(ip.split('.').map((x) => parseInt(x, 10))); + } + // For IPv6 literals, use URL parser trick + // Node doesn't provide direct conversion; implement simple parser: + const sections = ip.split('::'); + let head = []; + let tail = []; + if (sections.length === 1) { + head = ip.split(':'); + } else if (sections.length === 2) { + head = sections[0] ? sections[0].split(':') : []; + tail = sections[1] ? sections[1].split(':') : []; + } else { + // invalid + return Buffer.alloc(16); + } + // Expand head and tail into 8 blocks + const total = head.length + tail.length; + const fill = 8 - total; + const parts = [...head, ...Array(fill).fill('0'), ...tail].map((p) => parseInt(p || '0', 16)); + const buf = Buffer.alloc(16); + for (let i = 0; i < 8; i++) { + buf.writeUInt16BE(parts[i] || 0, i * 2); + } + return buf; +} + +function parseSocksRequest(buf) { + // VER CMD RSV ATYP DST.ADDR DST.PORT + if (buf.length < 4) return null; + const ver = buf.readUInt8(0); + if (ver !== 5) return { error: 'VERSION' }; + const cmd = buf.readUInt8(1); + const rsv = buf.readUInt8(2); + const atyp = buf.readUInt8(3); + let off = 4; + let host = null; + if (atyp === 0x01) { + if (buf.length < off + 4 + 2) return null; + host = Array.from(buf.subarray(off, off + 4)).join('.'); + off += 4; + } else if (atyp === 0x03) { + if (buf.length < off + 1) return null; + const len = buf.readUInt8(off); off += 1; + if (buf.length < off + len + 2) return null; + host = buf.subarray(off, off + len).toString('utf8'); + off += len; + } else if (atyp === 0x04) { + if (buf.length < off + 16 + 2) return null; + const b = buf.subarray(off, off + 16); + host = [...Array(8).keys()].map((i) => b.readUInt16BE(i * 2).toString(16)).join(':'); + off += 16; + } else { + return { error: 'ATYP' }; + } + const port = buf.readUInt16BE(off); off += 2; + return { cmd, host, port, size: off }; +} + +function buildSocksMethodsResponse(method) { + const buf = Buffer.allocUnsafe(2); + buf.writeUInt8(5, 0); + buf.writeUInt8(method, 1); + return buf; +} + +function parseUdpPayload(buf) { + 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 buildSocksUdpDatagram(host, port, data) { + const addrInfo = encodeSocksAddr(host); + const header = Buffer.allocUnsafe(2 + 1 + 1); + header.writeUInt8(0x00, 0); + header.writeUInt8(0x00, 1); + header.writeUInt8(0x00, 2); // FRAG=0 + header.writeUInt8(addrInfo.atyp, 3); + const portBuf = Buffer.allocUnsafe(2); + portBuf.writeUInt16BE(port >>> 0, 0); + return Buffer.concat([header, addrInfo.addr, portBuf, data]); +} + +function parseSocksUdpRequest(buf) { + // UDP request: RSV(2)=0x0000, FRAG(1), ATYP, DST.ADDR, DST.PORT, DATA + if (buf.length < 4) return null; + const rsv0 = buf.readUInt8(0), rsv1 = buf.readUInt8(1); + if (rsv0 !== 0x00 || rsv1 !== 0x00) return { error: 'RSV' }; + const frag = buf.readUInt8(2); + if (frag !== 0x00) return { error: 'FRAG' }; // we don't support fragmentation + const atyp = buf.readUInt8(3); + let off = 4; + let host = null; + if (atyp === 0x01) { // IPv4 + if (buf.length < off + 4 + 2) return null; + host = Array.from(buf.subarray(off, off + 4)).join('.'); + off += 4; + } else if (atyp === 0x03) { // domain + if (buf.length < off + 1) return null; + const len = buf.readUInt8(off); off += 1; + if (buf.length < off + len + 2) return null; + host = buf.subarray(off, off + len).toString('utf8'); off += len; + } else if (atyp === 0x04) { // IPv6 + if (buf.length < off + 16 + 2) return null; + const b = buf.subarray(off, off + 16); + host = [...Array(8).keys()].map((i) => b.readUInt16BE(i * 2).toString(16)).join(':'); + off += 16; + } else { + return { error: 'ATYP' }; + } + const port = buf.readUInt16BE(off); off += 2; + const data = buf.subarray(off); + return { host, port, data }; +} + +/* ============== Local SOCKS5 Server and Flow ============== */ + +function handleSocksConnection(localSocket) { localSocket.setNoDelay(true); localSocket.setKeepAlive(true, 30000); if (IDLE_TIMEOUT > 0) { @@ -322,169 +520,210 @@ function handleLocalConnection(localSocket) { }); } - const connectionId = genConnId(); - const state = { - id: connectionId, - localSocket, - bufferQueue: [], - opened: false, - openTimer: null, - mode: null, // 'CONNECT' | 'HTTP' - initialUpstream: null // Buffer to send immediately after OPEN_RESULT in HTTP mode - }; - connections.set(connectionId, state); - - let firstBuffer = Buffer.alloc(0); - let headersParsed = false; + let stage = 'NEGOTIATION'; + let buf = Buffer.alloc(0); + let tcpConnId = null; // for CONNECT + let udpAssocId = null; // for UDP ASSOCIATE function onData(chunk) { - if (!headersParsed) { - firstBuffer = Buffer.concat([firstBuffer, chunk]); + buf = Buffer.concat([buf, chunk]); - // Limit header size to prevent abuse (64KB) - if (firstBuffer.length > 64 * 1024) { - safeWrite(localSocket, Buffer.from( - 'HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n' - )); - destroyLocal(connectionId); + if (stage === 'NEGOTIATION') { + if (buf.length < 2) return; + const ver = buf.readUInt8(0); + const nmethods = buf.readUInt8(1); + if (ver !== 5) { + localSocket.end(); return; } + if (buf.length < 2 + nmethods) return; + const methods = Array.from(buf.subarray(2, 2 + nmethods)); + buf = buf.subarray(2 + nmethods); - const parsed = parseHeadersUntil(firstBuffer); - if (!parsed) { - // Wait for more + // We support only NO AUTH (0x00) + const method = methods.includes(0x00) ? 0x00 : 0xFF; + localSocket.write(buildSocksMethodsResponse(method)); + if (method === 0xFF) { + localSocket.end(); return; } - - headersParsed = true; - const { requestLine, rawHeaderLines, headers, rest } = parsed; - - const parts = requestLine.split(' '); - if (parts.length < 3) { - safeWrite(localSocket, Buffer.from( - 'HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n' - )); - destroyLocal(connectionId); - return; - } - - const method = parts[0]; - const target = parts[1]; - const version = parts[2]; - - if (method.toUpperCase() === 'CONNECT') { - state.mode = 'CONNECT'; - // target expected as host:port - let host = target; - let port = 443; - const cidx = host.lastIndexOf(':'); - if (cidx !== -1) { - port = parseInt(host.slice(cidx + 1), 10) || 443; - host = host.slice(0, cidx); - } - // Initiate OPEN - startOpen(connectionId, host, port, state); - // Any rest after headers (rare for CONNECT) will be buffered until open - if (rest.length > 0) { - state.bufferQueue.push(rest); - } - } else { - state.mode = 'HTTP'; - // Determine host, port, and path; rebuild request line to origin-form - let host = null; - let port = 80; - let path = target; - - if (/^http:\/\//i.test(target)) { - try { - const u = new URL(target); - host = u.hostname; - port = u.port ? parseInt(u.port, 10) : 80; - path = u.pathname + (u.search || ''); - if (path.length === 0) path = '/'; - } catch { - safeWrite(localSocket, Buffer.from( - 'HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n' - )); - destroyLocal(connectionId); - return; - } - } else if (/^https:\/\//i.test(target)) { - // We do not support absolute-form HTTPS over plaintext proxy without CONNECT - safeWrite(localSocket, Buffer.from( - 'HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 49\r\nContent-Type: text/plain\r\n\r\nUse CONNECT method for HTTPS requests through this proxy' - )); - destroyLocal(connectionId); - return; - } else { - // origin-form: must use Host header - const hostHeader = headers['host']; - if (!hostHeader) { - safeWrite(localSocket, Buffer.from( - 'HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n' - )); - destroyLocal(connectionId); - return; - } - const hostVal = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader; - const cidx = hostVal.lastIndexOf(':'); - if (cidx !== -1) { - host = hostVal.slice(0, cidx); - port = parseInt(hostVal.slice(cidx + 1), 10) || 80; - } else { - host = hostVal; - port = 80; - } - // path is already origin-form target - } - - // Rebuild request line to origin-form - const newRequestLine = buildRequestLine(method, path, version); - const headerStr = [newRequestLine, ...rawHeaderLines].join('\r\n') + '\r\n\r\n'; - state.initialUpstream = Buffer.concat([Buffer.from(headerStr, 'utf8'), rest]); - - // Initiate OPEN then send initialUpstream upon success - startOpen(connectionId, host, port, state); - } - return; + stage = 'REQUEST'; } - // After headers parsed: if not yet opened, buffer; else forward - if (!state.opened) { - state.bufferQueue.push(chunk); - } else { - writeMessage(tunnelSocket, MSG_TYPES.DATA, connectionId, chunk); + while (stage === 'REQUEST') { + const parsed = parseSocksRequest(buf); + if (parsed === null) return; // need more + if (parsed.error) { + // invalid + try { localSocket.write(buildSocksReply(0x01, '0.0.0.0', 0)); } catch (_) {} + localSocket.end(); + return; + } + const { cmd, host, port, size } = parsed; + buf = buf.subarray(size); + + if (cmd === 0x01 /* CONNECT */) { + // Allocate TCP connection id and OPEN through tunnel + tcpConnId = genConnId(); + const state = { + id: tcpConnId, + localSocket, + opened: false, + openTimer: null, + }; + connections.set(tcpConnId, state); + startOpenTcp(tcpConnId, host, port, state); + // Wait for OPEN_RESULT to send reply, then go streaming + stage = 'CONNECT_WAIT'; + + } else if (cmd === 0x03 /* UDP ASSOCIATE */) { + // Create UDP association + udpAssocId = genConnId(); + const as = { + id: udpAssocId, + controlSocket: localSocket, + udpSocket: null, + openTimer: null, + opened: false, + clients: new Set(), + }; + udpAssocs.set(udpAssocId, as); + + // Create local UDP socket for client to send datagrams to + const udpSock = dgram.createSocket('udp4'); + as.udpSocket = udpSock; + + udpSock.on('message', (msg, rinfo) => { + // Parse SOCKS5 UDP request + const req = parseSocksUdpRequest(msg); + if (!req || req.error) { + // ignore invalid/unhandled fragmentation + return; + } + // Track client endpoint so we can send replies back + as.clients.add(`${rinfo.address}:${rinfo.port}`); + // Send over tunnel as UDP_SEND + if (!tunnelSocket || tunnelSocket.destroyed) return; + writeMessage(tunnelSocket, MSG_TYPES.UDP_SEND, udpAssocId, buildUdpPayload(req.host, req.port, req.data)); + }); + + udpSock.on('error', () => { + // Close association + try { udpSock.close(); } catch (_) {} + writeMessage(tunnelSocket, MSG_TYPES.UDP_CLOSE, udpAssocId); + udpAssocs.delete(udpAssocId); + try { localSocket.write(buildSocksReply(0x01, '0.0.0.0', 0)); } catch (_) {} + try { localSocket.destroy(); } catch (_) {} + }); + + udpSock.bind(0, options.bindHost, () => { + // After binding, request UDP_OPEN on tunnel + if (!tunnelSocket || tunnelSocket.destroyed) { + try { localSocket.write(buildSocksReply(0x01, '0.0.0.0', 0)); } catch (_) {} + try { localSocket.destroy(); } catch (_) {} + return; + } + + as.openTimer = setTimeout(() => { + if (!as.opened) { + try { localSocket.write(buildSocksReply(0x06, '0.0.0.0', 0)); } catch (_) {} + writeMessage(tunnelSocket, MSG_TYPES.UDP_CLOSE, udpAssocId); + closeUdpAssoc(udpAssocId); + try { localSocket.destroy(); } catch (_) {} + } + }, OPEN_RESULT_TIMEOUT); + + writeMessage(tunnelSocket, MSG_TYPES.UDP_OPEN, udpAssocId); + }); + + if (UDP_IDLE_TIMEOUT > 0) { + udpSock.on('listening', () => { + udpSock.setRecvBufferSize?.(1 << 20); + }); + let idleTimer = setTimeout(() => { + try { udpSock.close(); } catch (_) {} + writeMessage(tunnelSocket, MSG_TYPES.UDP_CLOSE, udpAssocId); + udpAssocs.delete(udpAssocId); + try { localSocket.destroy(); } catch (_) {} + }, UDP_IDLE_TIMEOUT); + // Reset idle timer on activity + udpSock.on('message', () => { + clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + try { udpSock.close(); } catch (_) {} + writeMessage(tunnelSocket, MSG_TYPES.UDP_CLOSE, udpAssocId); + udpAssocs.delete(udpAssocId); + try { localSocket.destroy(); } catch (_) {} + }, UDP_IDLE_TIMEOUT); + }); + } + + // Keep stage as REQUEST to allow multiple requests? SOCKS typically one per TCP session. + // We keep control connection open until client closes. + stage = 'ASSOCIATED'; + + } else if (cmd === 0x02 /* BIND */) { + // Not supported + try { localSocket.write(buildSocksReply(0x07 /* Command not supported */, '0.0.0.0', 0)); } catch (_) {} + localSocket.end(); + return; + } else { + // Unknown + try { localSocket.write(buildSocksReply(0x07, '0.0.0.0', 0)); } catch (_) {} + localSocket.end(); + return; + } } } localSocket.on('data', onData); localSocket.on('close', () => { - // Inform remote - writeMessage(tunnelSocket, MSG_TYPES.CLOSE, connectionId); - connections.delete(connectionId); - clearTimeout(state.openTimer); + // For TCP CONNECT stream + if (tcpConnId !== null) { + writeMessage(tunnelSocket, MSG_TYPES.CLOSE, tcpConnId); + connections.delete(tcpConnId); + } + // For UDP associate + if (udpAssocId !== null) { + writeMessage(tunnelSocket, MSG_TYPES.UDP_CLOSE, udpAssocId); + closeUdpAssoc(udpAssocId); + } }); localSocket.on('error', () => { - writeMessage(tunnelSocket, MSG_TYPES.CLOSE, connectionId); - connections.delete(connectionId); - clearTimeout(state.openTimer); + if (tcpConnId !== null) { + writeMessage(tunnelSocket, MSG_TYPES.CLOSE, tcpConnId); + connections.delete(tcpConnId); + } + if (udpAssocId !== null) { + writeMessage(tunnelSocket, MSG_TYPES.UDP_CLOSE, udpAssocId); + closeUdpAssoc(udpAssocId); + } }); } -// Start local HTTP proxy server -const proxyServer = net.createServer((socket) => { - handleLocalConnection(socket); +function closeUdpAssoc(id) { + const as = udpAssocs.get(id); + if (!as) return; + try { as.udpSocket && as.udpSocket.close(); } catch (_) {} + udpAssocs.delete(id); +} + +/* ============== Server bootstrap ============== */ + +// Start local SOCKS5 server +const socksServer = net.createServer((socket) => { + handleSocksConnection(socket); }); -proxyServer.on('error', (err) => { - console.error('Local proxy server error:', err.message); +socksServer.on('error', (err) => { + console.error('Local SOCKS5 server error:', err.message); process.exit(1); }); -proxyServer.listen(parseInt(options.proxyPort, 10), options.bindHost, () => { - console.log(`Local HTTP proxy listening on ${options.bindHost}:${options.proxyPort}`); +socksServer.listen(parseInt(options.socksPort, 10), options.bindHost, () => { + console.log(`Local SOCKS5 proxy listening on ${options.bindHost}:${options.socksPort}`); }); // Connect tunnel and maintain it @@ -493,10 +732,14 @@ connectTunnel(); // Graceful shutdown process.on('SIGINT', () => { console.log('Shutting down...'); - try { proxyServer.close(); } catch (_) {} - try { if (tunnelSocket) tunnelSocket.destroy(); } catch (_) {} + try { socksServer.close(); } catch (_) {} + try { tunnelSocket && tunnelSocket.destroy(); } catch (_) {} for (const [id] of connections) { - destroyLocal(id); + destroyLocalTCP(id); + } + for (const [id] of udpAssocs) { + writeMessage(tunnelSocket, MSG_TYPES.UDP_CLOSE, id); + closeUdpAssoc(id); } process.exit(0); }); diff --git a/proxy-server.js b/proxy-server.js index 274090f..e83c1ec 100644 --- a/proxy-server.js +++ b/proxy-server.js @@ -3,23 +3,33 @@ /** * PSK Proxy Out-Node (Server) * - * Listens for a single 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, * 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 stream data - * CLOSE (3): Close a stream - * OPEN (4): Open stream to host:port, payload = [2B hostLen][host][2B port] - * OPEN_RESULT (5): Result for OPEN, payload = [1B status] (1 = success, 0 = failure) + * 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 fs = require('fs'); +const dgram = require('dgram'); const { program } = require('commander'); program @@ -47,6 +57,12 @@ const MSG_TYPES = { 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)) { @@ -109,6 +125,32 @@ function buildOpenResultPayload(success) { 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(`Tunnel client identity: ${identity}`); return Buffer.from(pskKey, 'hex'); @@ -117,109 +159,181 @@ function pskCallback(socket, identity) { let tunnelSocket = null; const upstreamConns = new Map(); // connectionId -> net.Socket +// UDP association mapping: connectionId -> { udp4?: dgram.Socket, udp6?: dgram.Socket } +const udpAssoc = new Map(); + function closeAllUpstreams() { - for (const [id, s] of upstreamConns) { + for (const [, s] of upstreamConns) { try { s.destroy(); } catch (_) {} } upstreamConns.clear(); } -const server = tls.createServer({ - pskCallback, - ciphers: 'PSK-AES256-GCM-SHA384:PSK-AES128-GCM-SHA256', -}, (socket) => { - console.log('Proxy tunnel client connected'); - tunnelSocket = socket; +function closeAllUdp() { + for (const [, obj] of udpAssoc) { + try { obj.udp4 && obj.udp4.close(); } catch (_) {} + try { obj.udp6 && obj.udp6.close(); } catch (_) {} + } + udpAssoc.clear(); +} - socket.setNoDelay(true); - socket.setKeepAlive(true, 30000); +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'); - const reader = createMessageReader(); + u4.on('message', onMessage); + u6.on('message', onMessage); - 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; - } + 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}`); }); - const { host, port } = spec; - // Create outbound TCP connection - const upstream = net.createConnection({ host, port }); - upstream.setNoDelay(true); - upstream.setKeepAlive(true, 30000); + // Bind to ephemeral ports to receive replies + try { u4.bind(0); } catch (_) {} + try { u6.bind(0); } catch (_) {} - let connected = false; - const connectTimer = setTimeout(() => { - if (!connected) { - upstream.destroy(new Error('Connect timeout')); + 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 tunnel client connected'); + tunnelSocket = 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; } - }, 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)); - }); + const { host, port } = spec; + // Create outbound TCP connection + const upstream = net.createConnection({ host, port }); + upstream.setNoDelay(true); + upstream.setKeepAlive(true, 30000); - upstream.on('data', (chunk) => { - writeMessage(tunnelSocket, MSG_TYPES.DATA, connectionId, chunk); - }); + let connected = false; + const connectTimer = setTimeout(() => { + if (!connected) { + upstream.destroy(new Error('Connect timeout')); + } + }, OUT_CONNECT_TIMEOUT); - const cleanup = () => { - clearTimeout(connectTimer); - if (upstreamConns.get(connectionId) === upstream) { + 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) => { + writeMessage(tunnelSocket, MSG_TYPES.DATA, connectionId, chunk); + }); + + const cleanup = () => { + clearTimeout(connectTimer); + if (upstreamConns.get(connectionId) === upstream) { + upstreamConns.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); } - 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(); + // 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)); } - }); - - 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.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 } - } else if (type === MSG_TYPES.CLOSE) { - const upstream = upstreamConns.get(connectionId); - if (upstream) { - upstream.destroy(); - upstreamConns.delete(connectionId); - } - } else { - // ignore unknown types - } + }); }); - }); - socket.on('close', () => { - console.log('Proxy tunnel disconnected'); - tunnelSocket = null; - closeAllUpstreams(); - }); + socket.on('close', () => { + console.log('Proxy tunnel disconnected'); + tunnelSocket = null; + closeAllUpstreams(); + closeAllUdp(); + }); - socket.on('error', (err) => { - console.error('Tunnel socket error:', err.message); - }); -}); + socket.on('error', (err) => { + console.error('Tunnel socket error:', err.message); + }); + } +); server.listen(parseInt(options.tunnelPort, 10), options.host, () => { console.log(`PSK Proxy Out-Node listening on ${options.host}:${options.tunnelPort}`); @@ -229,5 +343,6 @@ process.on('SIGINT', () => { console.log('Shutting down...'); try { server.close(); } catch (_) {} closeAllUpstreams(); + closeAllUdp(); process.exit(0); });